Skip to content

Sheet

Open settings, filters or details in a panel that slides from a screen edge.

import { Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@youj/react'

Installation & usage

npm i @youj/react

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Use Sheet for settings, filters, row details or logs that need more room than a Dialog. It is modal, so keep it to one task. Use a Popover for quick choices anchored to a button, or a Drawer for swipe dismissal.

import {
  Button,
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@youj/react'

export function FiltersSheet() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>Filters</SheetTrigger>
      <SheetContent side="end">
        <SheetHeader>
          <SheetTitle>Filters</SheetTitle>
          <SheetDescription>Narrow the deployments list.</SheetDescription>
        </SheetHeader>
        <SheetBody>
          <DeploymentFilters />
        </SheetBody>
        <SheetFooter>
          <SheetClose render={<Button variant="outline" />}>Cancel</SheetClose>
          <Button>Apply</Button>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}

A sheet is a column: SheetHeader and SheetFooter stay put while SheetBody scrolls between them. SheetContent renders the portal, the scrim and the panel, with a × button in its top corner. Sheet is Base UI's Dialog root, so open, onOpenChange and modal work as Base UI documents them.

For a form, wrap the header, body and footer in the <form> and give it flex min-h-0 flex-1 flex-col, so the body still scrolls and the footer's submit button belongs to the form. The Demo story does this, and it ignores close requests while the save runs.

Reference

Propsside, variant, backdrop, showCloseButton (4)

Props

SheetContent adds four props to Base UI's Dialog.Popup.

SheetContent props
PropType
sideSheetSide
'top''right''bottom''left''start''end'

The edge it slides from. start / end follow the writing direction.

default 'end'

'end'
showCloseButtonboolean

Renders the × button in the top corner.

default true

true
variant'docked' | 'floating'

docked sits flush on its edge. floating is inset from every edge, rounded and raised, and a bottom sheet on touch.

default 'docked'

'docked'
backdrop'dim' | 'blur'

The page behind: dim washes it with the scrim, blur also blurs it a little.

default 'dim'

'dim'
Other props pass through: extends SheetPrimitive.Popup.Props.

Sheet, SheetTrigger, SheetClose, SheetTitle and SheetDescription pass their props through to Base UI's Dialog parts. SheetHeader, SheetBody and SheetFooter are divs.

TouchOn a touch device a side sheet covers the screen's full width; a floating one is a bottom sheet

Touch

On a touch device a side sheet covers the screen's full width, up to 28rem on a tablet, so on a phone it reads as a page laid over the app rather than a panel with a sliver of page beside it. The × gets a 44px target, the content clears the notch and the home indicator, and the footer's actions share its width at 44px tall.

A floating sheet is different on touch: it becomes the bottom sheet a Dialog becomes there, with a grab handle, its body scrolling up to 90% of the screen, and pulled down to dismiss. Its header, body and footer keep their layout.

A docked sheet doesn't follow the finger. For a panel people pull away, which is what a bottom sheet on a phone usually wants, use Drawer. A Dialog already becomes a pull-to-dismiss bottom sheet on touch.

MotionEnter, exit, scrim in… · reduced motion

Motion

Motion tokens
MomentTokens
Enterslides fully in from its edge (yj-sheet-*)
duration.slowease.enter

340ms · cubic-bezier(0.16, 1, 0.3, 1)

340mscubic-bezier(0.16, 1, 0.3, 1)
Exitslides back off the same edge
duration.baseease.exit

220ms · cubic-bezier(0.4, 0, 1, 1)

220mscubic-bezier(0.4, 0, 1, 1)
Scrim inopacity (yj-backdrop)
duration.baseease.standard

220ms · cubic-bezier(0.2, 0, 0, 1)

220mscubic-bezier(0.2, 0, 0, 1)
Scrim out
duration.fastease.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)

Reduced motion

The sheet and its scrim appear and disappear at once, with no slide.

A floating sheet travels past its 12px gap too (--yj-sheet-gap), so nothing of it is left at the edge. With backdrop="blur" the blur is static and fades in with the scrim's opacity.

Only transform moves, so the slide never triggers layout. It runs on a CSS transition, so closing a sheet halfway in sends it back from where it is.

Reduced motion

The sheet is in place the moment it opens and gone the moment it closes, and the scrim does the same. Nothing else changes: focus, scrolling and dismissal behave exactly as they do with motion.

AccessibilityKeyboard, ARIA, focus

Accessibility

  • A sheet is a modal dialog: role="dialog", named by SheetTitle and described by SheetDescription. Always render a title.
  • Focus moves to the first control in the sheet when it opens, and stays inside it until it closes. The page behind is hidden from assistive technology and doesn't scroll.
  • Escape, a press on the scrim and the × button (named "Close") close it, and focus returns to the trigger.
  • The scrolling body becomes a tab stop only when it overflows with nothing focusable inside, so it never adds a pointless stop.