GitHubnpm

Accent theme

Base radius

GitHubnpm

Accent theme

Base radius

Colour scheme

Docs menu

Guides


Primitives
Forms
Layout
Feedback
Overlays
Navigation
Data Display
AI

Sheet

Dialog anchored to an edge, plus the gesture layer it doesn't have. Everything modal about it is still the platform's: it's a real <dialog> opened with showModal(), so the focus trap, top-layer stacking, Escape handling, ::backdrop, and focus restoration are native rather than reimplemented — no portal, no scroll-lock hack, no aria-modal. What Sheet adds is drag-to-dismiss with velocity detection, damped overdrag, and a backdrop that fades in step with the drag.

Recently played

Drag the sheet down, or grab the handle, to dismiss it.

  1. Weightless — Marconi Union
  2. An Ending (Ascent) — Brian Eno
  3. Avril 14th — Aphex Twin
  4. Rhubarb — Aphex Twin
  5. Everything In Its Right Place — Radiohead
  6. Svefn-g-englar — Sigur Rós
  7. Teardrop — Massive Attack
  8. Nightcall — Kavinsky
  9. Intro — The xx
  10. Open Eye Signal — Jon Hopkins
Usage
import { useState } from "react";
import { Button, Sheet } from "@kernelui-lib/react";

const [open, setOpen] = useState(false);

<Button onClick={() => setOpen(true)}>Recently played</Button>
<Sheet
  open={open}
  onOpenChange={setOpen}
  title="Recently played"
  description="Drag it down to dismiss."
  handleOnly
>
  <ol>{/* a long, scrolling list */}</ol>
</Sheet>

Playground

Toggle every adjustable prop and watch the component — and the code — update live.

Recently played

Drag toward the edge it's anchored to.

  1. Track 1
  2. Track 2
  3. Track 3
  4. Track 4
  5. Track 5
  6. Track 6
  7. Track 7
  8. Track 8
  9. Track 9
  10. Track 10
  11. Track 11
  12. Track 12
  13. Track 13
  14. Track 14
  15. Track 15
  16. Track 16
  17. Track 17
  18. Track 18
  19. Track 19
  20. Track 20
  21. Track 21
  22. Track 22
  23. Track 23
  24. Track 24
Unstyled

Same component — Kernel look stripped. See Platforms.

side
showHandle
handleOnly
dismissible
inset
showCloseButton
backdrop
scrollingBody
footer
snapPoints
spring
Usage
<Sheet open={open} onOpenChange={setOpen} title="Recently played">
  ...
</Sheet>

Props

Every Dialog prop applies, with side narrowed to the four edges and defaulting to "bottom". On top of those:

Props Sheet adds to Dialog.
PropTypeDefault
side"bottom" | "top" | "left" | "right""bottom"
showHandlebooleantrue
handleOnlybooleanfalse
dismissiblebooleantrue
closeThresholdnumber0.25
velocityThresholdnumber (px/ms)0.5
insetbooleanfalse
maxDisplayWidthnumber (px)
footerReactNode
snapPointsnumber[] (dvh / dvw by side)
snap / defaultSnapnumberlargest snap
onSnapChange(snap: number) => void
springboolean | { attraction, friction }true
onDrag(percent: number) => void
onRelease(open: boolean) => void
classNamesDialog's slots plus handle

Gestures

A drag dismisses when it either travels past closeThreshold of the sheet's own size or exceeds velocityThreshold — distance alone would reject a short, fast flick that clearly meant "go away". A distance dismissal is refused if the finger was travelling back the other way at the moment it lifted: position stays past the threshold through an entire reversal, so it's intent that decides, not where the sheet happens to sit. Dragging the other way isn't clamped at zero either; it's compressed asymptotically, so the sheet keeps answering the finger while never travelling further than its own size.

Release speed is measured over the last 100ms rather than averaged across the whole gesture. Averaging gets two things visibly wrong: a drag that travels a long way and then stops dead reads as a fast flick, and a drag reversed before release keeps reporting the direction it came from — so the sheet leaves in the opposite direction to the finger that pulled it back. This is also why velocityThreshold is 0.5 rather than Vaul's 0.11: the measure and the threshold only mean anything as a pair.

Dragging never fights scrolling, and the decision isn't made once. A gesture starting inside a scroll container belongs to that container while it has anywhere left to go; the sheet re-asks on every move, so pulling a list to its top and continuing in the same motion hands the gesture over mid-drag. It hands over without a jump, too — travel is measured from the moment of handoff, not from where the finger first went down. Set handleOnly when you'd rather remove the contest entirely: the handle sits outside the scroll container, so a handle drag is never ambiguous.

The gesture only ever writes an inline translate, and clears it on release. The resting position stays the stylesheet's, which is what lets a dismissal continue smoothly from wherever the finger left off instead of snapping back to the start of an animation first.

Variants

inset detaches the sheet from the screen: a gap on three sides and all four corners rounded. It's entirely CSS — no JavaScript branch reads it — and the gap sizes from --kernel-sheet-inset-inline and --kernel-sheet-inset-block. The off-screen resting position grows by the same gap, so a detached sheet doesn't park with its edge still showing, and the overdrag bleed is dropped, since a floating sheet has real page on every side rather than a strip to cover.

maxDisplayWidth is for the common pairing of a sheet on small screens and a centred Dialog on large ones. Above the limit the sheet closes itself — on open and on resize alike. It can't refuse to open, since open is your state rather than the component's, but closing immediately lands in the same place.

Snap points

snapPoints gives the sheet resting sizes as percentages of the viewport — [25, 55, 92] is the shape every maps app converges on: a peek, a half, and nearly full. A flick steps exactly one snap in its own direction, which is what makes the sheet feel detented rather than momentum-driven; a slower release lands on whichever snap is nearest. Dragging below the smallest snap dismisses, so "flick down from the peek" and "drag it off the bottom" are one gesture.

Snapping works on every side. A snap always means the same thing — how much of the screen the sheet takes up — so it's a percentage of the extent the sheet grows along: dvh for bottom and top, dvw for left and right. Nothing else changes with the side; the gesture, thresholds, and settle are identical.

Snapping sheets are driven by their size rather than translate, because a snap is a size: that's what keeps a pinned footer pinned and sizes the scroll region to what's actually on screen. A translate-based peek would lay its content out for the full sheet and push the footer off the bottom. Below the smallest snap the size pins and travel moves back onto translate, so dismissal is the same code path as a binary sheet's.

Snaps are stored in viewport units, never pixels, which is what makes a resize free — 55dvh still means the same thing afterwards, so there's nothing to recompute. Dragging past the largest snap resists rather than tracking the finger, and snap/defaultSnap/onSnapChange follow Kernel's usual controlled/uncontrolled split. A snapping sheet carries data-snap with its current snap, which is both a styling hook and what lifts the size cap a binary sheet uses to stay clear of the opposite screen edge — without that, a snap larger than the cap would be silently clamped to it.

The spring

Kernel's motion baseline is CSS transitions, and that still holds for everything reversible. A snap settle is the one case a curve can't express: the sheet has to leave at the speed the finger was actually moving, and a cubic-bezier has no notion of an initial velocity. So spring is on by default for snap settles and nothing else reaches for it — a binary sheet keeps its transition, because there's no target the stylesheet doesn't already know.

The equation is solved once and then evaluated at elapsed time, rather than integrated frame by frame. That's not premature precision: a per-frame integrator makes the path depend on how the frames land, so a 120Hz display and a 60Hz display take measurably different routes and a dropped frame stretches the motion. Solving it means frame rate and jitter decide only when the movement is sampled, never what it is.

Pass { attraction, friction } to tune — higher attraction is springier, higher friction settles sooner and overshoots less — or false to hand the settle back to CSS. Under prefers-reduced-motion the settle is instant regardless, while dragging still tracks the finger: direct manipulation is the user's own movement, not decoration the component added.

Footer

footer pins a row below the scrolling body — an action bar, a total, a Checkout button. Dialog gives one content wrapper for all children, so a footer placed among them would scroll away with the rest; Sheet splits that wrapper into a sheet-body that scrolls and a sheet-footer that doesn't.

The footer, not the sheet, then owns the bottom safe area, and cancels the sheet's inline padding so its background reaches both edges. Both matter for the same reason: a footer inset from the bottom leaves a strip of bare surface between it and the home indicator. An inset sheet is the exception — its gap already clears the indicator, so the footer doesn't add it twice. Like the handle, the footer is a drag surface, and it's what handleOnly permits alongside the handle.

Composition

The body scrolls, which makes it a scroll container in both axes — overflow-y: auto forces overflow-x to match, as CSS has no way to scroll one axis and overflow the other. Content that paints outside its own box at the sheet's inline edge is therefore clipped, with no way to scroll to it: an <ol>'s default list-style-position: outside marker, a focus ring on a control flush to the edge, a child's box-shadow. Give a numbered list enough padding-inline-start for its widest marker — a two-digit "10." is about 23px at the default size.

Every Dialog data-slot hook, plus sheet-handle, sheet-body, and sheet-footer. During a drag the dialog carries data-dragging and a --kernel-sheet-drag-progress custom property counting from 1 down to 0 — ::backdrop inherits custom properties from its originating element, which is how the scrim tracks the drag with no overlay node to keep in sync. Size is overridable per side via --kernel-sheet-max-inline-size, --kernel-sheet-max-block-size, --kernel-sheet-inline-size, and --kernel-sheet-handle-space.

useSheetDrag is exported too, for dragging a surface that isn't a Sheet.

Accessibility

  • The handle is aria-hidden decoration. Dragging is never the only way out: Escape, the close button, and the backdrop all still work, so a keyboard or screen-reader user never needs the gesture.
  • That's also why the handle can be positioned at the anchored edge rather than coming first in the DOM — the <header> and its title stay the first thing in reading order.
  • dismissible={false} disables dragging and backdrop dismissal together, but deliberately leaves Escape alone; provide your own explicit close control.
  • prefers-reduced-motion drops the slide transition. Dragging still tracks the finger — it's direct manipulation, not decoration.
  • Body copy inside the sheet stays selectable; only the chrome and the handle opt out, since a native text selection starting on the handle cancels the gesture.