Sheets
components specs/components/sheets.kmd
Surface anchored to an edge of the screen, slidable to reveal secondary content — bottom sheets (mobile primary) and side sheets (tablet/desktop). Material parity (`/components/bottom-sheets` and `/components/side-sheets`). Covers modal vs standard, drag gestures, scrim, focus trap, and snap points.
When this spec applies
Primary triggers
- Implement a bottom sheet
- Implement a side sheet
All triggers
- Add a peek-up panel from the bottom of mobile screen
- Show contextual options without leaving the page
- Reveal a secondary pane that user can dismiss
Specification body
Spec — Sheets
Facet Visual of Koder Design. Material parity: https://m3.material.io/components/bottom-sheets and https://m3.material.io/components/side-sheets.
2 anchor positions × 2 modalities
| Anchor | Modality | Mobile | Tablet/Desktop |
|---|---|---|---|
| Bottom — modal | Blocks page | ✓ Primary | Use dialog instead |
| Bottom — standard | Inline, page still interactive | ✓ Secondary | Rare |
| Side — modal | Blocks page | — | ✓ Primary |
| Side — standard | Inline, page still interactive | — | ✓ Common (3-pane layout) |
Pick anchor by surface: bottom on mobile (thumb-reach); side on tablet/desktop (wider real estate).
Anatomy (bottom sheet, modal)
▼ scrim (40% black overlay)
┌──────────────────────────────────────┐
│ ━━━ │ ← drag handle
│ Sheet title │
│ ────────────────────────────────── │
│ Content row 1 │
│ Content row 2 │
│ Content row 3 │
│ │
│ [Confirm] [Cancel] │
└──────────────────────────────────────┘
(anchored to bottom edge)
- Top corner radius: 28 px (bottom corners flush with screen)
- Drag handle: 4 px × 32 px pill, centered, 22 px from top
- Container bg:
surface-container-low - Elevation: 1 dp (modal scrim does the visual lift)
- Padding: 24 px horizontal, 16 px vertical
- Min height: 50% of viewport (default open state)
- Max height: 90% of viewport (leaves room to dismiss by tap above)
Anatomy (side sheet, standard)
┌────────────────────────┬───────────────────┐
│ │ Sheet title │
│ Main content │ ──────────────── │
│ │ Detail content │
│ │ │
│ │ │
└────────────────────────┴───────────────────┘
←── 320-400 dp ──→
- Width: 320-400 dp (fixed; not draggable in width)
- Anchor: right edge (default; left for RTL or 3-pane layouts)
- Border: 1 px
outline-varianton inner edge - Container bg:
surface-container-low - No corner radius on the screen-edge corners
R1 — Modality
| Modality | Scrim | Focus trap | Dismiss |
|---|---|---|---|
| Modal | Yes (40% black) | Yes | Scrim tap + drag-down + Esc + close × |
| Standard | No | No | Close × button only OR programmatic |
Modal sheet behaves like a dialog with bottom/side anchor: blocks page until dismissed. Standard sheet stays open and lets user interact with the rest of the page.
R2 — Bottom sheet snap points
| Snap | Height | Use |
|---|---|---|
| Closed | 0 px (hidden) | Initial state |
| Peek | 25% viewport OR ~120 px | Optional teaser visible |
| Half | 50% viewport | Default open |
| Expanded | 90% viewport | User dragged up |
| Full | 100% viewport | Becomes full-screen sheet |
Drag handle moves between snap points; velocity > 500 px/s expands or collapses past midpoint.
Peek snap is OPTIONAL — most sheets have only Closed → Half → Expanded.
R3 — Side sheet snap points
| Snap | Width | Use |
|---|---|---|
| Closed | 0 px (hidden) | Initial state |
| Open | 320-400 dp | Default |
| Wide | 50% viewport | User expanded (rare) |
Side sheets snap at fixed widths; don't free-resize like a window pane. If user needs free resize, use a resizable pane layout (not sheet).
R4 — Drag gesture
- Bottom sheet: drag handle bar OR anywhere in the sheet header
- Drag down: collapse to next snap (Expanded → Half → Closed)
- Drag up: expand to next snap (Half → Expanded)
- Velocity-based: fast flick passes through all snaps
- Side sheet: usually NO drag gesture (button-controlled)
- Content inside sheet scrolls independently — drag must originate on handle or header, not on scrolling content
Disabled when:
- Reduced motion preference active (still snaps, no smooth follow)
- Sheet is in Expanded state and content is mid-scroll
R5 — Scrim
Modal sheets ONLY. Scrim is 40% opacity black overlay covering the rest of the screen. Tap scrim → dismiss sheet.
Side sheet (modal) scrim covers the main content area, NOT the side sheet itself.
Standard sheets have NO scrim — user can interact with the page around the sheet.
R6 — Focus trap and keyboard
Modal sheets trap focus when open:
- Tab cycles within sheet, never escapes
- Esc dismisses (calls onDismiss callback)
- On open: focus moves to first focusable element OR sheet title (if labelled)
- On close: focus returns to the trigger element
Standard sheets do NOT trap focus — Tab moves naturally between sheet and page.
R7 — Mobile keyboard interaction
When mobile soft keyboard opens while a bottom sheet is showing:
- Sheet animates up to remain visible above keyboard
- Sheet snap point becomes "above keyboard" until keyboard closes
- Don't shrink sheet content height; let it scroll
R8 — Animation
- Open: slide-in from edge (motion-medium, ~250 ms) + scrim fade-in (modal only)
- Close: slide-out (motion-medium) + scrim fade-out
- Snap transition: spring animation (Material 3 emphasized decelerate, ~350 ms)
- Drag follow: 1:1 with finger; no spring during drag
- Reduced motion: instant in/out; no spring; no drag-follow easing (still works, just snaps)
R9 — Accessibility
- Modal sheet:
role="dialog"+aria-modal="true"+aria-labelledbypointing to sheet title - Standard sheet:
role="complementary"+aria-labeldescribing the sheet's purpose - Drag handle:
role="button"+aria-label="Drag to resize"+ keyboard support (Arrow Up/Down to expand/collapse) - Dismiss button:
aria-label="Close sheet" - Screen reader announces sheet on open: "Settings, dialog"
R10 — Per-preset variation
| Preset | Bottom sheet | Side sheet |
|---|---|---|
material3 | 28 px top corners, drag handle | Flush edges, no handle |
material2 | 16 px top corners, no handle | Flush, 4 dp shadow |
ios_cupertino | 16 px top corners, swipe-down to dismiss | Inspector-style overlay |
gnome | Adwaita BottomBar style, no handle (button-controlled) | Sidebar embedded in window |
windows_11 | Mica backdrop, system-style close × | Acrylic sidebar |
brutalist | Sharp top corners, 4 px thick top border | Sharp edges, thick border |
terminal_classic | ASCII box at bottom of screen | Vertical pane via tmux-style split |
R11 — Density
Inherits surface density from customization.kmd. Bottom sheet
default padding 24 px / 16 px → compact 16 px / 12 px → comfortable
32 px / 20 px.
R12 — Forbidden patterns
- ❌ Stacking sheets (sheet over sheet — use single sheet with navigation inside, or break into separate flows)
- ❌ Bottom sheet on Expanded/Large window-size class (use side sheet or dialog)
- ❌ Side sheet narrower than 320 dp (cramped)
- ❌ Side sheet wider than 50% of viewport (defeats sheet semantics; switch to full-page or dialog)
- ❌ Standard sheet with scrim (contradicts "non-modal")
- ❌ Modal sheet without focus trap
- ❌ Drag-to-resize that loses content position (scroll state must survive snap changes)
- ❌ Dismiss-by-scroll-content (content scroll triggers sheet dismissal — confusing; only handle drag dismisses)
- ❌ Bottom sheet without bottom safe-area inset
Cross-link
app-layout/safe-area.kmd— bottom sheet bottom insetapp-layout/window-size-classes.kmd— when to choose bottom vs sidethemes/elevation.kmd— modal scrim rolethemes/color-roles.kmd—surface-container-lowtokeninteraction/states.kmd— handle hover/presscomponents/dialogs.kmd— sibling modal pattern (centered vs edge-anchored)foundations/elements.kmd— Container family
Requirements (testable)
Requirement: Modal vs standard modality {#req-sheets-modality}
A sheet SHALL apply modal treatment (40% black scrim, focus trap, and multi-path dismiss) when its modality is modal, and SHALL apply standard treatment (no scrim, no focus trap, close-button-or-programmatic dismiss only) when its modality is standard; a standard sheet SHALL NOT render a scrim.
Scenario: Modal sheet renders scrim and dismisses on scrim tap
- GIVEN a bottom sheet configured with modality
modal - WHEN the sheet is opened
- THEN a scrim overlay at 40% opacity black covers the rest of the screen
- AND a
tapon the scrim invokes the sheet's dismiss and closes the sheet
Scenario: Standard sheet has no scrim and page stays interactive
- GIVEN a side sheet configured with modality
standard - WHEN the sheet is opened
- THEN no scrim element is present in the rendered tree
- AND a
pointerevent on the main content area outside the sheet reaches and activates the underlying page control
Requirement: Bottom sheet snap points and drag {#req-sheets-bottom-snap}
A bottom sheet SHALL move between its defined snap points (Closed 0px, Half ~50% viewport, Expanded ~90% viewport, plus optional Peek) in response to a drag originating on the drag handle or header, and a drag velocity greater than 500 px/s SHALL carry the sheet past the midpoint to the next snap in the drag direction.
Scenario: Drag up expands from Half to Expanded
- GIVEN a bottom sheet resting at the Half snap point (~50% viewport height)
- WHEN the user drags the drag handle upward past the midpoint toward Expanded
- THEN the sheet settles at the Expanded snap point (~90% viewport height)
Scenario: Fast downward flick collapses to Closed
- GIVEN a bottom sheet at the Half snap point
- WHEN the user flicks the handle downward with velocity greater than 500 px/s
- THEN the sheet passes through intermediate snaps and settles at Closed (dismissed)
Scenario: Scroll on content does not dismiss the sheet
- GIVEN a bottom sheet whose content region is mid-scroll
- WHEN a drag gesture originates on the scrolling content rather than the handle or header
- THEN the sheet does not change snap point
- AND the content scrolls independently
Requirement: Focus trap and keyboard on modal sheets {#req-sheets-focus-trap}
A modal sheet SHALL trap keyboard focus while open — Tab cycling within the sheet, Esc invoking the dismiss callback, focus moving on open to the first focusable element or the labelled title, and focus returning to the trigger element on close; a standard sheet SHALL NOT trap focus.
Scenario: Tab stays inside the modal sheet
- GIVEN an open modal sheet containing two focusable controls
- WHEN the user presses Tab repeatedly past the last focusable control
- THEN focus cycles back to the first focusable control within the sheet and never lands on a page element behind the sheet
Scenario: Esc dismisses and restores trigger focus
- GIVEN a modal sheet opened from a trigger button
- WHEN the user presses Esc
- THEN the sheet's onDismiss callback is invoked and the sheet closes
- AND focus returns to the trigger button
Scenario: Standard sheet lets Tab leave the sheet
- GIVEN an open standard sheet adjacent to interactive page content
- WHEN the user presses Tab from the sheet's last focusable control
- THEN focus moves to the next focusable element on the page outside the sheet
Requirement: Accessibility roles and labels {#req-sheets-a11y}
A sheet SHALL expose the correct ARIA semantics: a modal sheet with role="dialog", aria-modal="true", and aria-labelledby referencing its title; a standard sheet with role="complementary" and a descriptive aria-label; a drag handle with role="button", aria-label="Drag to resize", and Arrow Up/Down keyboard control; and a dismiss button with aria-label="Close sheet".
Scenario: Modal sheet advertises dialog semantics
- GIVEN a modal bottom sheet whose title element has id
sheet-title - WHEN the sheet is rendered open
- THEN the sheet container has
role="dialog",aria-modal="true", andaria-labelledby="sheet-title"
Scenario: Drag handle is keyboard operable
- GIVEN a modal bottom sheet at the Half snap point with keyboard focus on the drag handle
- WHEN the user presses Arrow Up
- THEN the sheet expands to the next snap point
- AND the handle exposes
role="button"witharia-label="Drag to resize"
Requirement: Forbidden sheet configurations {#req-sheets-forbidden}
The sheet component SHALL NOT permit prohibited configurations: stacking a sheet over another sheet, rendering a bottom sheet on an Expanded/Large window-size class, a standard sheet with a scrim, a modal sheet without a focus trap, a side sheet narrower than 320 dp or wider than 50% of the viewport, or a bottom sheet without a bottom safe-area inset.
Scenario: Bottom sheet on large window class is rejected
- GIVEN a large/expanded window-size class
- WHEN a bottom sheet is requested
- THEN the component refuses the bottom-sheet configuration and directs to a side sheet or dialog
Scenario: Bottom sheet includes bottom safe-area inset
- GIVEN a bottom sheet rendered on a device reporting a nonzero bottom safe-area inset
- WHEN the sheet is displayed at any open snap point
- THEN the sheet content bottom padding includes the reported bottom safe-area inset
Scenario: Out-of-range side sheet width is rejected
- GIVEN a side sheet requested with a width of 280 dp
- WHEN the sheet is configured
- THEN the configuration is rejected as below the 320 dp minimum
References
specs/foundations/elements.kmdspecs/themes/elevation.kmdspecs/themes/color-roles.kmdspecs/interaction/states.kmdspecs/app-layout/safe-area.kmdspecs/components/dialogs.kmd