Theme
On this page (8)
Component Skill — components/sheet/component-skill.md

Native basis

<dialog> element + showModal(). Same native benefits as Dialog:

Web Platform APIs

<dialog>HTMLDialogElement.showModal()::backdrop@starting-style

Classes

.sheet.sheet-content.sheet-header.sheet-title.sheet-description.sheet-body.sheet-footer.sheet-close-x

Data attributes

• data-side - values: right, left, top, bottom

• data-sheet-trigger

• data-sheet-close

Wiring conventions

• data-sheet-trigger="[id]" on any element → opens that sheet

• data-sheet-close on any element inside → closes the sheet

• Click on backdrop → closes (click lands on <dialog> itself)

• Place <dialog> elements as direct children of <body>

Notes

• While a sheet is modal, html:has(dialog.sheet:modal) sets overflow: hidden + scrollbar-gutter: stable - the page behind cannot scroll and its position is preserved for when the sheet closes (no JS scroll-lock).

• Right/left sheets have a fixed width of 24rem with max-width: 100vw for small screens.

• Top/bottom sheets are full width with height: auto - they size to their content.

• The selector is dialog.sheet (element + class) to avoid conflicts with dialog.dialog.

• The sheet-header has padding-right: 2rem to avoid overlapping the close button.

§Right

Default side. The sheet slides in from the right edge - commonly used for edit-profile or settings panels.

§Left

Slides in from the left edge - ideal for navigation menus or sidebar-style panels.

§Top

Slides down from the top edge - useful for notifications or announcement banners.

§Bottom

Slides up from the bottom edge - great for cookie consent, action sheets, or preference panels.

§Density

Set data-density on the {''} root to scale the content padding. A whitespace policy, not a zoom: only padding scales (ratio 0.75 / 1 / 1.25), the slide-in width and typography stay identical. comfortable matches the unsized default.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - closed, off-screen (the authored state; the trigger opens it)
  • open - shown modally via showModal(), slid in from its side

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/sheet-{state}.png.

Machine contract - verified against sheet.schema.json by bun run verify:

StateTypeValuesDefaultDescription
openbooleantrue, falsefalseShown via showModal(); hidden via close() (native open property).

§CSS view file

/* -- Sheet component ------------------------------------------- */
@layer components {
  /* -- Base -------------------------------------------------- */
  dialog.sheet {
    border: none;
    border-radius: 0;
    background-color: var(--background);
    color: var(--foreground);
    padding: 0;
    margin: 0;
    max-width: none;
    max-height: none;
    opacity: 0;
    transition: opacity 300ms ease, transform 300ms ease, display 300ms allow-discrete;
    &[open] { opacity: 1; }
    /* -- Side: right (default) --------------------------------- */
    &, &[data-side="right"] {
      position: fixed;
      top: 0; right: 0; bottom: 0;
      left: auto;
      width: 24rem;
      max-width: 100vw;
      max-height: 100vh;
      height: 100%;
      border-left: 1px solid var(--border);
      transform: translateX(100%);
    }
    &[open], &[data-side="right"][open] {
      transform: translateX(0);
    }
    /* -- Side: left -------------------------------------------- */
    &[data-side="left"] {
      position: fixed;
      top: 0; left: 0; bottom: 0;
      right: auto;
      width: 24rem;
      max-width: 100vw;
      max-height: 100vh;
      height: 100%;
      border-left: none;
      border-right: 1px solid var(--border);
      transform: translateX(-100%);
      &[open] { transform: translateX(0); }
    }
    /* -- Side: top --------------------------------------------- */
    &[data-side="top"] {
      position: fixed;
      top: 0; left: 0; right: 0;
      bottom: auto;
      width: 100%;
      max-width: 100vw;
      /* a sheet, not a page: the backdrop always shows (content scrolls
         inside the modal past this) */
      max-height: 85dvh;
      height: auto;
      border-left: none;
      border-bottom: 1px solid var(--border);
      transform: translateY(-100%);
      &[open] { transform: translateY(0); }
    }
    /* -- Side: bottom ------------------------------------------ */
    &[data-side="bottom"] {
      position: fixed;
      bottom: 0; left: 0; right: 0;
      top: auto;
      width: 100%;
      max-width: 100vw;
      /* a sheet, not a page: the backdrop always shows (content scrolls
         inside the modal past this) */
      max-height: 85dvh;
      height: auto;
      border-left: none;
      border-top: 1px solid var(--border);
      transform: translateY(100%);
      &[open] { transform: translateY(0); }
    }
    /* -- Backdrop ---------------------------------------------- */
    &::backdrop {
      background: oklch(0 0 0 / 0);
      backdrop-filter: blur(0px);
      transition: all 300ms ease, display 300ms allow-discrete;
    }
    &[open]::backdrop {
      background: oklch(0 0 0 / 0.45);
      backdrop-filter: blur(3px);
    }
  }
  @starting-style {
    dialog.sheet[open],
    dialog.sheet[data-side="right"][open] {
      opacity: 0;
      transform: translateX(100%);
    }
    dialog.sheet[data-side="left"][open] {
      opacity: 0;
      transform: translateX(-100%);
    }
    dialog.sheet[data-side="top"][open] {
      opacity: 0;
      transform: translateY(-100%);
    }
    dialog.sheet[data-side="bottom"][open] {
      opacity: 0;
      transform: translateY(100%);
    }
    dialog.sheet[open]::backdrop {
      background: oklch(0 0 0 / 0);
      backdrop-filter: blur(0px);
    }
  }
  /* -- Scroll lock ------------------------------------------- */
  /* Page behind stays put while a sheet is modal (see dialog.css):
     `:modal` + overflow:hidden freezes the viewport at its current offset. */
  html:has(dialog.sheet:modal) {
    overflow: hidden;
    scrollbar-gutter: stable;
  }
  /* -- Content sections -------------------------------------- */
  .sheet-content     { padding: 1.5rem; position: relative; }
  /* -- Density ----------------------------------------------------
     data-density on the .sheet root scales the content padding
     (0.75 / 1 / 1.25 of the 1.5rem default); comfortable matches the
     unsized default. The sheet's slide-in width is layout, not whitespace —
     it stays put. */
  .sheet:where([data-density="compact"]) .sheet-content     { padding: 1rem; }
  .sheet:where([data-density="comfortable"]) .sheet-content { padding: 1.5rem; }
  .sheet:where([data-density="spacious"]) .sheet-content    { padding: 2rem; }
  /* Top/bottom sheets span the full viewport width - on wide screens the
     content would stretch unaligned, so keep a readable, centered column
     (the side sheets are already width-constrained at 24rem). */
  dialog.sheet[data-side="top"] .sheet-content,
  dialog.sheet[data-side="bottom"] .sheet-content {
    max-width: 48rem;
    box-sizing: border-box; /* the 1.5rem padding is part of the 48rem column */
    width: 100%;
    margin-inline: auto;
  }
  .sheet-header      { margin-bottom: 1rem; padding-right: 2rem; }
  .sheet-title       { font-size: 1.0625rem; font-weight: 600; margin: 0 0 0.375rem; letter-spacing: -0.01em; }
  .sheet-description { font-size: 0.875rem; color: var(--muted-foreground); margin: 0; line-height: 1.6; }
  .sheet-body        { margin-top: 1rem; }
  .sheet-footer      { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1.5rem; }
  /* -- Close button ------------------------------------------ */
  .sheet-close-x {
    position: absolute;
    top: 1rem; right: 1rem;
    width: 1.75rem; height: 1.75rem;
    display: flex;
    align-items: center;
    justify-content: center;
    border: none;
    background: transparent;
    color: var(--muted-foreground);
    border-radius: var(--radius-sm);
    cursor: pointer;
    transition: color 150ms, background-color 150ms;
    &:hover {
      color: var(--foreground);
      background-color: var(--accent);
    }
  }
}
/* Accessibility: suppress motion for users who request it (REQUIRED for all
   components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of
   `none` keeps transitionend/animationend (and discrete display flips)
   firing so JS state machines that await them keep working. */
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .sheet,
    .sheet *,
    .sheet::before,
    .sheet::after,
    .sheet *::before,
    .sheet *::after,
    .sheet::backdrop,
    .sheet-body,
    .sheet-body *,
    .sheet-body::before,
    .sheet-body::after,
    .sheet-body *::before,
    .sheet-body *::after,
    .sheet-body::backdrop,
    .sheet-close-x,
    .sheet-close-x *,
    .sheet-close-x::before,
    .sheet-close-x::after,
    .sheet-close-x *::before,
    .sheet-close-x *::after,
    .sheet-close-x::backdrop,
    .sheet-content,
    .sheet-content *,
    .sheet-content::before,
    .sheet-content::after,
    .sheet-content *::before,
    .sheet-content *::after,
    .sheet-content::backdrop,
    .sheet-description,
    .sheet-description *,
    .sheet-description::before,
    .sheet-description::after,
    .sheet-description *::before,
    .sheet-description *::after,
    .sheet-description::backdrop,
    .sheet-footer,
    .sheet-footer *,
    .sheet-footer::before,
    .sheet-footer::after,
    .sheet-footer *::before,
    .sheet-footer *::after,
    .sheet-footer::backdrop,
    .sheet-header,
    .sheet-header *,
    .sheet-header::before,
    .sheet-header::after,
    .sheet-header *::before,
    .sheet-header *::after,
    .sheet-header::backdrop,
    .sheet-title,
    .sheet-title *,
    .sheet-title::before,
    .sheet-title::after,
    .sheet-title *::before,
    .sheet-title *::after,
    .sheet-title::backdrop {
      transition-duration: 0.01ms !important;
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
    }
  }
}

§JavaScript view file

Identical pattern to Dialog. Wire triggers via data-sheet-trigger, close buttons via data-sheet-close, and backdrop click. Targets dialog.sheet elements.

// -- Sheet ----------------------------------------------------
// Wires [data-sheet-trigger] buttons to <dialog class="sheet"> elements,
// plus the named-state API so agents/tests can drive open/closed by name
// (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals } from '../../shared/state-api.js';
const df$ = defussGlobals();
const sheetStates = ['default', 'open'];
/**
 * UI side of setState: 'default' closes, 'open' opens modally. Native
 * <dialog> mechanics; close-focus-return is handled by the close listener.
 */
function triggerStateChange(sheet, stateName, _config) {
  switch (stateName) {
    case 'default':
      if (sheet.open) sheet.close();
      break;
    case 'open':
      if (!sheet.open) sheet.showModal();
      break;
  }
}
/** Registry-level API; pass the sheet element explicitly. Unknown names throw. */
export const sheetApi = {
  setState(sheet, stateName, config = {}) {
    if (!sheetStates.includes(stateName)) {
      throw new Error(`sheet: unknown state "${stateName}" (supported: ${sheetStates.join(', ')})`);
    }
    triggerStateChange(sheet, stateName, config);
    // state lives on the ELEMENT, not the module (multiple sheets per page)
    sheet.dataset.stateName = stateName;
    sheet._stateConfig = config;
  },
  getState(sheet) {
    return { name: sheet.dataset.stateName || 'default', config: sheet._stateConfig ?? {} };
  },
};
df$.sheetApi = sheetApi;
df$.sheetStates = sheetStates;
function init() {
document.querySelectorAll('[data-sheet-trigger]:not([data-init])').forEach((trigger) => {
  trigger.dataset.init = '';
  const sheet = document.getElementById(trigger.dataset.sheetTrigger);
  if (!sheet) return;
  trigger.addEventListener('click', () => {
    sheet._trigger = trigger;
    sheet.showModal();
  });
});
document.querySelectorAll('dialog.sheet:not([data-init])').forEach((sheet) => {
  sheet.dataset.init = '';
  // bind-scope the api per instance: `$('#sheet-right').api.setState('open')`
  sheet.api = {
    setState: (stateName, config) => sheetApi.setState(sheet, stateName, config),
    getState: () => sheetApi.getState(sheet),
  };
  sheet.addEventListener('click', (e) => {
    if (e.target === sheet) sheet.close();
  });
  sheet.querySelectorAll('[data-sheet-close]').forEach((btn) => {
    btn.addEventListener('click', () => { sheet.close(); });
  });
  sheet.addEventListener('close', () => {
    // `close` fires AFTER the exit transition (display allow-discrete), so a
    // fast re-open can beat it - a stale event must not downgrade an open
    // sheet back to 'default' or yank focus out of it while it's showing.
    if (sheet.open) return;
    // reflect the actual UI state: any close path (Escape, backdrop, close
    // button) returns the sheet to 'default', even when it wasn't setState'd
    sheet.dataset.stateName = 'default';
    if (sheet._trigger) sheet._trigger.focus();
  });
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub