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.

Narrow the window below 40rem and open it again — it arrives from the bottom instead

Share this export

<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.

How the sheet differs above and below 40rem
Below 40rem40rem 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.

The body scrolls; the header stays

Pick a category

<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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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-labelledby pointing 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-body pads for the home indicator; the sheet does not pad for a notch, because it never reaches the top of the screen — the 88dvh cap 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-block in 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. 88dvh minus 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.