layout: app shells, padding lives only on leaves

Six shell patterns, each shown wrong (the container pads) and right (only leaf nodes pad). Containers arrange with flex, grid and gap. Press i for the inspector to see which node owns padding: amber is a leaf pad, red is a negative-margin hack.

leaf pad negative-margin hack

holy grail

header, nav, main, aside and footer in one grid.

wrong · the frame pads

acme
overview

body copy

v1.2.0

padding on the frame fights every full-bleed element: the title bar needs a negative margin to reach the edges.

right · the leaves pad

acme
overview

body copy

v1.2.0

each region pads its own content. a full-bleed title bar is just a child with no padding.

ruling

the grid container never pads. every region owns its padding, so any region can go edge to edge without undoing someone else's spacing.

B markup + rules
<header class="lay-topbar"> <!-- container: arrange only -->
<span class="lay-brand">acme</span> <!-- leaf: pads itself -->
<Button size="sm">new</Button> <!-- leaf: the Button pads itself -->
</header>
.lay-topbar { display:flex; align-items:center;
justify-content:space-between;
border-bottom:1px solid var(--rule); } /* no padding here */
.lay-brand { padding:0.75rem; }

collapsible sidebar and icon rail

collapse is one attribute on the rail; icons stay centered because the items, not the rail, own the width.

wrong · the rail pads

when the width flips, labels snap and icons drift: the rail's padding is part of every width calculation.

right · the items pad

collapse changes one width. items keep their own padding, so the icon column never moves.

ruling

collapse = a class or attribute flip. if padding lives on the container, one flip re-flows everything inside it.

B markup + rules
<nav class="lay-rail" data-rail> <!-- container -->
<a class="lay-railitem" data-pad>…</a> <!-- leaf -->
</nav>
.lay-rail { width:11rem; display:flex;
flex-direction:column; gap:0.25rem; }
.lay-rail[data-collapsed] { width:3.5rem; } /* the whole collapse */
.lay-rail[data-collapsed] .lay-raillabel { display:none; }
.lay-railitem { height:2.5rem; align-items:center;
gap:0.75rem; padding-inline:1.25rem; }

master-detail

a scrolling list beside a reading pane. separators are the list's job, not each item's.

wrong · the list pads

payout sent

your payout of $12,400.00 is on the way.

every item drags its own border around with negative margins, and the sticky header has to out-pad the pane.

right · the items pad

payout sent

your payout of $12,400.00 is on the way.

divide-y draws all separators once. the sticky header needs no counter-margins.

ruling

repeating separators belong to the list (divide-y), never to the item. an item that draws its own border will fight scroll, hover and selection states.

B markup + rules
<div class="lay-maillist"> <!-- container: draws dividers -->
<a class="lay-mailitem">
<b class="lay-mailfrom">stripe</b> <!-- leaf -->
<span class="lay-mailsubj">payout sent</span>
</a>
</div>
.lay-maillist > * + * { border-top:1px solid var(--rule); }
.lay-mailfrom { padding:0.75rem 1rem 0; }
.lay-mailsubj { padding:0 1rem 0.75rem; }

three-pane

channel list, thread, and context pane.

wrong · every pane pads

channelsgeneraldesignrandom
# design

today: shipping the resize handle.

each pane pads, so every edge element (heads, composer) needs a negative-margin escape hatch.

right · the leaves pad

channelsgeneraldesignrandom
# design

today: shipping the resize handle.

heads sit flush against their pane edges because the pane pads nothing.

B markup + rules
<section class="lay-thread"> <!-- container: gap only -->
<div class="lay-panehead"># design</div>
<div class="lay-composer">
<Input placeholder="message" className="m-3 flex-1" /> <!-- lone leaf insets itself -->
</div>
</section>
.lay-thread { display:flex; flex-direction:column; gap:0.5rem; }
.lay-panehead { border-bottom:1px solid var(--rule);
padding:0.5rem 0.75rem; }

dashboard widget grid

auto-fill tracks; the widgets, not the grid, carry their insides. drag the slider to resize the container.

container width1100px

wrong · the grid pads

revenue$48.2k
signups1,204
top pages/pricing/docs/blog
latency212ms

the grid's padding shifts every breakpoint; widgets can never bleed their sparklines to the card edge.

right · the widgets pad

revenue$48.2k
signups1,204
top pages/pricing/docs/blog
latency212ms

a widget is a container: it arranges (gap), its leaves pad, and the sparkline touches the card edges for free.

B markup + rules
<div class="lay-dash"> <!-- grid: tracks + gap -->
<div class="lay-widget"> <!-- container: gap -->
<b class="lay-metric">$48.2k</b> <!-- leaf -->
<span class="lay-spark"></span> <!-- bleeds: owns no pad -->
</div>
</div>
.lay-dash { display:grid; gap:1rem;
grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); }
.lay-widget { display:flex; flex-direction:column; gap:0.5rem;
overflow:hidden; border-radius:0.5rem; }
.lay-metric { padding-inline:1rem; }

resizable split

the handle is a leaf too: one column wide, and the panes never pad around it.

wrong · the panes pad

editor.rs

fn main() { println!("hi") }

preview

the tab bar needs negative margins to reach the pane edge, and every resize re-snaps it.

right · the leaves pad

editor.rs

fn main() { println!("hi") }

preview

drag the handle: the left pane resizes, the tab bar stays glued to its edge.

ruling

the split owns widths; panes own arrangement; leaves own padding. dragging changes one inline width and nothing re-flows.

B markup + rules
<div class="lay-split"> <!-- flex: owns widths -->
<div class="lay-splitpane is-left"> <!-- container -->
<div class="lay-tabbar">editor.rs</div> <!-- leaf -->
</div>
<div class="lay-handle"></div> <!-- leaf: one column wide -->
</div>
.lay-split { display:flex; overflow:hidden; }
.lay-splitpane.is-left { width:50%; flex-shrink:0; }
.lay-tabbar { border-bottom:1px solid var(--rule);
padding:0.5rem 1rem; }
.lay-handle { width:0.25rem; cursor:col-resize; }

search pages

go to any page