Layout and Hierarchy
Installation
$ npx skills add Mihirmodi27/interface-skills --skill layout-and-hierarchySummary
Structure interfaces around a single reading measure — one column every page shares, anchored rails in the outer margin, asymmetric spacing that groups rather than separates, a nested radius ladder with continuous corners, a documented z-index ladder, and content gating so an empty section removes its route and its nav entry rather than showing an empty state.
Fires when
Use when laying out pages, setting a content width, spacing sections, choosing border radii or hairline weights, building sticky sidebars or tables of contents, deciding what changes between desktop and touch, ordering stacking contexts, handling empty states, or reviewing a layout that feels cramped, arbitrary, or inconsistently spaced.
In the box
SKILL.md, 2 assets, 3 references
On this page
- 1. One measure
- 2. Rails live outside the column, anchored to a wider frame
- 3. Spacing groups; asymmetry is the mechanism
- 4. The radius ladder nests
- 5. Hairlines: 0.5px vs 1px
- 6. Two presentations, not one responsive layout
- 7. Content gating: no empty states
- 8. The z-index ladder
- 9. Structural accessibility
- Assets
SKILL.md
Structure for interfaces where the content is mostly text and the layout's job is to get out of its way.
One decision drives most of the rest: there is a single reading measure, and every page uses it. Home, article, index, long-form — all 640px. Navigating between them doesn't move the text. Everything else in this skill is either about that column, or about what you're allowed to put outside it.
Reference implementation: modimihir.com.
1. One measure
.col {
max-width: 640px;
margin: 0 auto;
padding: 0 32px;
}
640px with 32px of padding — so the text is at most 576px wide, which at 16px type is roughly 75–85 characters per line. That's the upper end of comfortable and the right place to be for a site that expects sustained reading.
Every page uses the same class. The value of that isn't aesthetic consistency, it's continuity: clicking from a summary on the home page into the long version on another page doesn't shift the text, so the transition reads as expansion rather than as a new document.
Three rules that fall out of it:
- Content that isn't text can leave the column — an image wall goes full-bleed, because a masonry grid has nothing to do with a reading measure.
- A cover image inside an article stays in the column. Bleeding it wide turns it into a banner above the article rather than part of it.
- The column is the divider width too. A hairline that spans the viewport separates the page; a hairline at column width separates the content.
2. Rails live outside the column, anchored to a wider frame
The pattern for a sticky table of contents:
<div className="relative mx-auto max-w-[1280px]">
{/* Absolute, so it sits outside the reading column entirely */}
<div className="absolute inset-y-0 left-8 hidden w-[196px] xl:block">
<div className="sticky top-28 max-h-[calc(100dvh-11rem)] overflow-y-auto">
<SectionNav … />
</div>
</div>
<div className="col">{/* the reading column, untouched */}</div>
</div>
Both the rail and the column are anchored to the same centred 1280px frame. That's what makes the gap between them hold steady from 1280px up — at 1600px or 2400px the rail doesn't drift further from the text. A rail positioned relative to the viewport instead separates from the content it indexes as the window grows.
Three details:
absolute+ astickychild. The wrapper spans the section's full height so the sticky child has a track to travel; sticky alone inside a flex row would need its own column and would push the reading column off-centre.max-h-[calc(100dvh-11rem)]+overflow-y-auto. A long table of contents must scroll inside itself rather than run off the screen.dvh, notvh— mobile browser chrome changes the viewport height, andvhdoesn't notice.xl:block. Below 1280px there's no margin to put it in, so it becomes a disclosure above the text. See §6.
3. Spacing groups; asymmetry is the mechanism
Space is how you say what belongs together. Equal space on both sides of an element says it belongs to neither neighbour — which is almost never what you mean.
mt-11 (44px) ← heading
mb-3 (12px)
← the paragraph it introduces
A ~3.5:1 ratio above versus below. The heading and what follows it are one unit; the gap above is what separates that unit from the previous one. This is the single most common spacing mistake and the cheapest to fix.
The same logic at every scale:
| Relationship | Inner | Outer | Ratio |
|---|---|---|---|
| Heading → its paragraph | 12px below | 44px above | 3.7:1 |
| List items → the next block | 8px between | 24px below | 3:1 |
| Bio paragraphs → the links after | 16px between | 32px after last | 2:1 |
| Section heading → its content | 16–32px | 36px+ between sections | ~2:1 |
Section rhythm on the home page is py-9 (36px top and bottom) — symmetric here, because sections are peers rather than parent-and-child. Long pages use a heavier separation: mt-16 border-t pt-10 (64px, a hairline, 40px), where the rule earns its place by marking a genuine change of subject.
Not every boundary gets a rule. On the home page, Work / Experience / Life get a hairline above them; Watching and Socials don't, because they read as a coda to Life rather than as peers. That's a typographic judgement about rhythm — and it belongs in the layout code, not in a config file, precisely because it's a design call and not a preference.
→ The full spacing scale and where each value is used: references/measure-and-rhythm.md
4. The radius ladder nests
rounded-full pills, tags, dots
rounded-md 6 tooltips
rounded-[9px] 9 small cards in a stack
rounded-[10px]10 an avatar inside a button
rounded-xl 12 cards, hover panels, disclosures
rounded-[12px]12 icon buttons, highlight bars
rounded-2xl 16 the outer container (a dock, a sheet)
Nesting rule: the inner radius should be the outer radius minus the padding between them. A 16px dock with 4px of padding contains 12px buttons. A 12px button holding a 1px-inset avatar gives it 10px. Get this wrong and you see it immediately — the corners either look pinched (inner too large) or the gap opens up at the corners (inner too small).
* , *::before, *::after {
corner-shape: superellipse(1.5);
}
Continuous ("squircle") corners globally. superellipse(k) uses exponent 2^k — k=1 is a normal round corner, k=2 is a full iOS squircle, so 1.5 lands about halfway. The curvature changes gradually into the straight edge instead of meeting it at a discontinuity, which is why iOS icons look softer than a same-radius CSS rectangle.
Progressive enhancement: unsupported browsers ignore the property and get normal rounding. Nothing else in the layout depends on it.
5. Hairlines: 0.5px vs 1px
| Weight | For |
|---|---|
0.5px |
Chrome borders, dividers, glass edges |
1px |
Card borders, rings, disclosure outlines |
2px |
Quote rules, focus rings |
1px (h-px) |
Table-of-contents markers |
0.5px renders as a true sub-pixel hairline on retina and rounds up to 1px elsewhere — so it degrades to the next weight rather than disappearing.
The choice is about what the border is doing. A divider inside a dock is separating two groups of icons in the same object: 0.5px. A border around a card is defining the edge of a distinct object: 1px.
And on translucent surfaces, use the alpha ramp (border-gray-alpha-400) — a solid border on glass doesn't participate in what's behind it and reads as a pasted-on frame. See the color-and-theming skill.
A divider that isn't an <hr> should be aria-hidden, and an <hr> used as a divider needs border-0 before you give it a background, or you get the UA border and your line.
6. Two presentations, not one responsive layout
The most consequential structural idea here. Where the interaction model genuinely differs between pointer and touch, ship two components, not one component with breakpoint classes.
<nav aria-label="Primary" className="pointer-events-none fixed inset-x-0 bottom-5 z-50 flex justify-center">
<MobileDock /> {/* sm:hidden — avatar + a menu button opening a sheet */}
<DesktopDock /> {/* hidden sm:block — hover-revealed icons, sliding highlight */}
</nav>
The desktop dock reveals its pages and its settings box on hover, and collapses on scroll. Touch fires none of that. A single responsive component would need a hover path, a touch path, a collapse rule that only applies to one of them, and a settings box reachable two different ways — more branching than two focused components, and harder to reason about.
The same pattern for the table of contents: SectionNav (a sticky rail) and SectionNavCompact (a <details> disclosure) render the same tree through a shared Rows component. Share the data and the row rendering; fork the container.
The test for when to fork: does the interaction model change, or just the arrangement? Arrangement → breakpoints. Interaction model → two components.
→ Adaptive presentations, capability queries, the touch-versus-hover audit: references/adaptive-presentations.md
7. Content gating: no empty states
One flag, three places:
const PAGES = [
has.experience && { to: "/experience", … },
has.playground && { to: "/playground", … },
has.writing && { to: "/writing", … },
].filter(Boolean);
{has.writing && <Route path="/writing" element={<Writing />} />}
<Route path="*" element={<Navigate to="/" replace />} />
When a content collection is empty, it loses its nav icon, its route, and its home-page entry — all three, derived from the same flag. There is no empty state, because there is no way to reach the thing that would show one.
Sections drop out before dividers are placed, so a hairline can never end up hanging above nothing:
const shown = site.sections.filter((name) => SECTIONS[name] && sectionHasContent[name]);
And the catch-all redirect means an old link to a page you've since emptied lands somewhere real rather than on a blank screen.
Layout consequences of the same principle — a layout should look deliberate at every content count:
// One or two folders stay folder-sized and centred rather than stretching
// across a three-up grid. (640px column − gaps) ÷ 3 ≈ 200px each.
const COLS = ["", "grid-cols-1", "grid-cols-2", "grid-cols-3"];
const WIDTH = ["", "max-w-[200px]", "max-w-[420px]", ""];
A single item stretched to full width is the tell that a grid wasn't designed for its own edge cases.
(Tailwind scans source text, so these must be literal class strings — a computed grid-cols-${n} never reaches the stylesheet.)
8. The z-index ladder
Documented, sparse, and ordered by permanence:
| z | Layer |
|---|---|
| 100 | Skip link — must beat everything |
| 50 | Fixed navigation |
| 40 | Tap-outside catcher — under the nav, over the page |
| 30 | A pocket front, over its own contents |
| 20 | Hover cards, dropdowns |
| 10 | A hovered item lifting above its siblings |
| −10 | A sliding highlight, behind content inside isolate |
Wide gaps so something can be inserted without renumbering. The 40/50 pair is the interesting one: a full-screen tap-outside catcher must be above the page and below the menu it dismisses, or it eats the menu's own clicks.
Two techniques worth knowing:
isolate + negative z. A highlight bar behind icons but in front of its container's background: isolate on the container scopes the -z-10 so the bar can't fall behind the parent.
pointer-events-none on a full-width strip, auto on the content. A fixed nav strip spans the viewport for centring but shouldn't intercept clicks along its whole width:
<nav className="pointer-events-none fixed inset-x-0 bottom-5 z-50 flex justify-center">
<div className="pointer-events-auto">{/* the dock */}</div>
</nav>
Note the strip carries no transform — a transform would make it the containing block for the mobile sheet's full-bleed overlay, and the overlay would be trapped inside the strip's bounds.
→ Radius derivation, elevation tiers, stacking contexts: references/radius-and-elevation.md
9. Structural accessibility
Not a separate pass — these are layout decisions.
<a href="#main" className="skip">Skip to content</a>
Parked off-screen with a transform (never display: none, which removes it from the tab order) and slid in on focus. Every page needs a matching id="main".
aria-labelon every<nav>. "Primary", "On this page" — a page with three unlabelled navs is unnavigable by landmark.- Heading levels follow structure, not size. A page title is
h1, sectionsh2, sub-sectionsh3, cardsh4. Two elements at 14px/500 can legitimately beh3andh4. Never pick a level for its default size. scroll-mton every anchor target, matched to the scroll spy's reading line — otherwise a jump lands the heading above the line and the TOC highlights the previous section.aria-currenton the active row,aria-pressedon toggles,aria-expanded+aria-haspopup+aria-controlson menu triggers.- A menu's wrapper stays mounted while the panel unmounts, so
aria-controlskeeps resolving to a real element. - Decorative spans get
aria-hidden. A hand-rolled bullet inside a real<li>would otherwise be announced. - Real anchors for in-page links, with the click handler layered on top — so they work without JS and can be copied as links.
<details>/<summary>for a disclosure rather than a div and state. Keyboard behaviour,aria-expanded, and find-in-page all come free.
@media print {
nav, footer, .skip { display: none; }
.reveal { opacity: 1; transform: none; }
a { color: inherit; text-decoration: none; }
}
Print is a real presentation. The .reveal reset is the one people forget — scroll-triggered content prints blank without it.
Assets
assets/layout-tokens.css— the column, the spacing scale, radius ladder, continuous corners, hairlines, skip link, print styles.assets/SectionNav.tsx— two presentations of one tree, with shared row rendering.