Sheet
.sheet is one <dialog> that is two components. Below
40rem it is a bottom sheet — full width, rounded at the top, rising from
the bottom edge. Above it, it is a centred modal that fades and scales. Same markup,
same showModal(); the media query decides.
When to use it
Use it for the things a phone reader reaches for with a thumb: a share menu, a set of filters, a short form, a picker. The bottom of the screen is the easiest place to reach on a phone and the hardest on a monitor, which is exactly why the component changes shape rather than staying a sheet everywhere.
<button class="btn btn-primary" onclick="document.getElementById('dx-sheet').showModal()">Share</button>
<dialog class="sheet" id="dx-sheet" aria-labelledby="dx-sheet-t">
<div class="sheet-grip"></div>
<div class="sheet-header">
<h3 class="sheet-title" id="dx-sheet-t">Share this export</h3>
<button class="btn btn-sm btn-ghost btn-icon push" aria-label="Close" onclick="this.closest('dialog').close()">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#x"></use></svg>
</button>
</div>
<div class="sheet-body">
<button class="btn btn-block">Copy link</button>
<button class="btn btn-block">Email a copy</button>
<button class="btn btn-block">Download CSV</button>
</div>
</dialog>
<button class="btn btn-primary" onclick="document.getElementById('dx-sheet').showModal()">Share</button>
<dialog class="sheet" id="dx-sheet" aria-labelledby="dx-sheet-t">
<div class="sheet-grip"></div>
<div class="sheet-header">
<h3 class="sheet-title" id="dx-sheet-t">Share this export</h3>
<button class="btn btn-sm btn-ghost btn-icon push" aria-label="Close" onclick="this.closest('dialog').close()">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#x"></use></svg>
</button>
</div>
<div class="sheet-body">
<button class="btn btn-block">Copy link</button>
<button class="btn btn-block">Email a copy</button>
<button class="btn btn-block">Download CSV</button>
</div>
</dialog>
One dialog, two shapes
The two states are worth reading side by side, because the difference is not only position — the animation changes too, and it changes for a reason.
| Below 40rem | 40rem and up | |
|---|---|---|
| Width | 100% | min(30rem, 100vw − 2rem) |
| Position | margin-block-start: auto — pinned to the bottom |
margin: auto — centred |
| Corners | Rounded at the top only | Rounded all round |
| Animation | translate: 0 100% — slides up |
Fades and scales from .97 |
The animation changes because the two shapes imply different things. A panel attached to an edge should arrive from that edge — the movement explains where it came from and where it will go. A centred dialog is attached to nothing, so sliding it up from the bottom of the screen would be travel with no story; fading and scaling reads as appearing in place.
The mobile transition is 300ms and the desktop one is 190ms, for the same reason a drawer is slower than a modal: distance travelled needs time to read as travel.
The grip
.sheet-grip is a 38×4 pill centred at the top of the sheet. It is
the platform convention that says "this panel can be dragged down to dismiss", and it
is worth knowing that Deck draws it and does not implement the drag.
Swiping the sheet down does nothing.
That makes it the same shape of problem as the
file drop zone: an affordance that promises a gesture
the page does not handle. It is milder here — the grip also reads as a decorative
handle, and a close button is usually present — but it is a promise nonetheless.
Recorded in FINDINGS.md.
touch-action: pan-y is not set on the sheet, so there is nothing to build
a drag on either. If you add one, the pieces are a pointerdown on the
grip, a translate that follows the pointer, and a threshold past which you call
close() instead of springing back.
Header and body
.sheet-body scrolls, with overscroll-behavior: contain so
reaching its end does not start scrolling the page behind it. The sheet caps at
88dvh, leaving a strip of backdrop visible at the top — which is what
tells the reader there is a page behind, and gives them somewhere to tap if you add
light dismiss.
The body's bottom padding is
calc(var(--space-6) + env(safe-area-inset-bottom)), so the last control
clears the home indicator on a phone rather than sitting underneath it.
<button class="btn" onclick="document.getElementById('dx-sheet-long').showModal()">A long sheet</button>
<dialog class="sheet" id="dx-sheet-long" aria-labelledby="dx-sheet-long-t">
<div class="sheet-grip"></div>
<div class="sheet-header"><h3 class="sheet-title" id="dx-sheet-long-t">Pick a category</h3></div>
<div class="sheet-body">
<label class="check"><input type="radio" name="dx-cat" checked><span>Exports</span></label>
<label class="check"><input type="radio" name="dx-cat"><span>Invoices</span></label>
<label class="check"><input type="radio" name="dx-cat"><span>Members</span></label>
<label class="check"><input type="radio" name="dx-cat"><span>Webhooks</span></label>
<label class="check"><input type="radio" name="dx-cat"><span>Audit log</span></label>
<label class="check"><input type="radio" name="dx-cat"><span>Domains</span></label>
<button class="btn btn-primary btn-block" onclick="this.closest('dialog').close()">Done</button>
</div>
</dialog>
<button class="btn" onclick="document.getElementById('dx-sheet-long').showModal()">A long sheet</button>
<dialog class="sheet" id="dx-sheet-long" aria-labelledby="dx-sheet-long-t">
<div class="sheet-grip"></div>
<div class="sheet-header"><h3 class="sheet-title" id="dx-sheet-long-t">Pick a category</h3></div>
<div class="sheet-body">
<label class="check"><input type="radio" name="dx-cat" checked><span>Exports</span></label>
<label class="check"><input type="radio" name="dx-cat"><span>Invoices</span></label>
<label class="check"><input type="radio" name="dx-cat"><span>Members</span></label>
<label class="check"><input type="radio" name="dx-cat"><span>Webhooks</span></label>
<label class="check"><input type="radio" name="dx-cat"><span>Audit log</span></label>
<label class="check"><input type="radio" name="dx-cat"><span>Domains</span></label>
<button class="btn btn-primary btn-block" onclick="this.closest('dialog').close()">Done</button>
</div>
</dialog>
Classes
Generated from src/08-mobile.css by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.sheet |
deck.mobile |
<dialog class="sheet"> on a phone, a centred modal from 40rem up. | 08-mobile.css:11 |
.sheet-body |
deck.mobile |
The scrolling region. Pads for the home indicator, and contains its overscroll so reaching the end does not scroll the page behind. | 08-mobile.css:59 |
.sheet-grip |
deck.mobile |
The drag handle at the top of a bottom sheet. Deck draws it and does not implement the drag — swiping down does nothing — so always ship a real close button as well. | 08-mobile.css:46 |
.sheet-header |
deck.mobile |
The title row of a sheet. | 08-mobile.css:54 |
.sheet-title |
deck.mobile |
The sheet's heading. Point aria-labelledby at it. | 08-mobile.css:56 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--ink-300 |
oklch(84.6% calc(var(--chroma-neutral) * 2.2) var(--hue-neutral)) |
|
--text |
light-dark(var(--ink-900), var(--ink-100)) |
|
--text-md |
1.0625rem |
|
--r-lg |
18px |
|
--r-full |
999px |
|
--shadow-4 |
0 8px 16px oklch(20% .03 var(--shadow-color) / .08), 0 24px 56px oklch(20% .03 var(--shadow-color) / .14) |
|
--space-1 |
.25rem |
|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--space-5 |
1.25rem |
|
--space-6 |
1.5rem |
|
--space-8 |
2rem |
|
--dur-2 |
190ms |
|
--dur-3 |
300ms |
|
--ease-spring |
linear(0, .18 6%, .53 14%, .89 24%, 1.04 32%, 1.06 40%, 1) |
|
--hue-neutral |
232 |
Accessibility
-
It is a dialog, so it behaves like one — focus trapped, Escape
closes, background inert, focus returned to the trigger. All from
showModal(). -
Name it with
aria-labelledbypointing at.sheet-title, as the examples do. -
The grip is decorative and unlabelled, which is correct — it is an
empty
<div>and a screen reader ignores it. It is also, per above, a gesture affordance with no gesture behind it. Always ship a real close button as well. - A close button is not optional on a phone. There is no Escape key on a touch keyboard, and Deck adds no light dismiss. Without a button the only exits are the browser's back gesture and a hardware key.
-
Safe-area insets are handled at the bottom, not the top.
.sheet-bodypads for the home indicator; the sheet does not pad for a notch, because it never reaches the top of the screen — the88dvhcap keeps it clear. - Two shapes, one focus order. The media query changes only presentation, so the tab order, the reading order and the announcement are identical at every width. This is what makes a responsive component swap safe.
-
Full-width buttons in the body are deliberate. A
.btn-blockin a bottom sheet is a large, easy target at the bottom of a phone screen, which is the whole reason for choosing this shape.
Right to left
The mobile sheet's radii are border-start-start-radius and
border-start-end-radius — logical corners — so the rounding stays on the
top two corners in any writing direction. The animation is on the block axis, which
does not mirror. There is nothing direction-specific to get wrong here, which is
unusual for a component this size.
<button class="btn" onclick="document.getElementById('dx-sheet-rtl').showModal()">مشاركة</button>
<dialog dir="rtl" class="sheet" id="dx-sheet-rtl" aria-labelledby="dx-sheet-rtl-t">
<div class="sheet-grip"></div>
<div class="sheet-header"><h3 class="sheet-title" id="dx-sheet-rtl-t">مشاركة التصدير</h3></div>
<div class="sheet-body">
<button class="btn btn-block">نسخ الرابط</button>
<button class="btn btn-block" onclick="this.closest('dialog').close()">إغلاق</button>
</div>
</dialog>
<button class="btn" onclick="document.getElementById('dx-sheet-rtl').showModal()">مشاركة</button>
<dialog dir="rtl" class="sheet" id="dx-sheet-rtl" aria-labelledby="dx-sheet-rtl-t">
<div class="sheet-grip"></div>
<div class="sheet-header"><h3 class="sheet-title" id="dx-sheet-rtl-t">مشاركة التصدير</h3></div>
<div class="sheet-body">
<button class="btn btn-block">نسخ الرابط</button>
<button class="btn btn-block" onclick="this.closest('dialog').close()">إغلاق</button>
</div>
</dialog>
Reduced motion
Both animations — the slide below 40rem, the fade-and-scale above — are
transitions, so the global reset collapses them to .01ms under
prefers-reduced-motion: reduce. The sheet appears and disappears in
place.
A slide of the sheet's full height is the largest movement in Deck's interactive set,
so this is one of the components where the reduced-motion path matters most. As with
modal, display and overlay
remain in the transition with allow-discrete, so closing still completes.
Printing
Two things happen. dialog::backdrop { display: none } stops an open
sheet printing a grey wash over the page, and .sheet-grip is listed
among the chrome that never prints — it is a gesture affordance, and paper has no
gestures.
The sheet itself is not otherwise adjusted, because it holds controls rather than content. If what is inside one needs to be printable, it belongs in the page.
Overriding it
@layer app.components {
/* A taller sheet on a phone */
.sheet { max-block-size: 94dvh; }
/* Keep it a bottom sheet at every width */
@media (min-width: 40rem) {
.sheet {
inline-size: 100%;
max-inline-size: 100%;
margin: 0;
margin-block-start: auto;
border-radius: var(--r-lg) var(--r-lg) 0 0;
}
}
}
Undoing the desktop shape means undoing four declarations, because the media query changes four things. If you find yourself doing that, you probably want a component that is always a bottom sheet rather than an override of one that is not.
When not to use it
-
Not for a confirmation. Use
.modal. A sheet is a surface for choosing; a confirmation is a question, and it should be centred and small at every width. -
Not for a long form.
88dvhminus a header leaves very little on a phone once the keyboard is up. Give it a page. - Not on desktop-only interfaces. If nobody will see the phone shape, you are using a component with two behaviours to get one of them. Use a modal.
- Not with "swipe down to close" in the copy — until the drag is implemented, that is an instruction the page cannot follow.
-
Not for a menu of actions on desktop. Use
.menu, which is anchored to its trigger and dismisses on an outside click. A sheet blocks the page.