Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

OverlaysReact

Drawer

A panel that slides in from the edge of the screen.

Rendered by React 19.3.1

Loading React…
import { Drawer } from '@hamur/react/drawer'

export default function DrawerExample() {
  return (
    <Drawer.Root swipeDirection="right">
      <Drawer.Trigger className="button">
        Open drawer
      </Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Backdrop className="backdrop drawer-backdrop" />
        <Drawer.Viewport className="drawer-viewport">
          <Drawer.Popup className="drawer">
            <Drawer.Content>
              <Drawer.Title className="dialog-title">
                Filters
              </Drawer.Title>
              <Drawer.Description className="muted">
                Narrow the list down. Swipe right or press Esc to close.
              </Drawer.Description>
              <div className="actions">
                <Drawer.Close className="button">
                  Done
                </Drawer.Close>
              </div>
            </Drawer.Content>
          </Drawer.Popup>
        </Drawer.Viewport>
      </Drawer.Portal>
    </Drawer.Root>
  )
}
579Tests passing in React · 0 failing
45.7 KiBSize
1.90 msOpen
2.20 msClose
18.0 msOpenMutations
21.0 msCloseMutations
0.78 MiBMemory
productdevbook and 13 upstream authors8 commits · last

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<Drawer.Root>
  <Drawer.Trigger />
  <Drawer.Portal>
    <Drawer.Backdrop />
    <Drawer.Viewport>
      <Drawer.Popup>
        <Drawer.Content>
          <Drawer.Title />
          <Drawer.Description />
          <Drawer.Close />
        </Drawer.Content>
      </Drawer.Popup>
    </Drawer.Viewport>
  </Drawer.Portal>
</Drawer.Root>

// Not in this example: Drawer.Indent, Drawer.IndentBackground, Drawer.Provider, Drawer.SwipeArea, Drawer.VirtualKeyboardProvider

Examples

Position

swipeDirection matches the side the drawer comes from.

DrawerPosition.tsx
import { Drawer } from '@hamur/react/drawer'

export default function DrawerPositionExample() {
  return (
    <Drawer.Root>
      <Drawer.Trigger className="button">Open bottom drawer</Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Backdrop className="backdrop drawer-backdrop" />
        <Drawer.Viewport className="sheet-viewport">
          <Drawer.Popup className="sheet">
            <div className="sheet-handle" />
            <Drawer.Content className="sheet-content">
              <Drawer.Title className="sheet-title">Notifications</Drawer.Title>
              <Drawer.Description className="sheet-description">
                You are all caught up. Good job!
              </Drawer.Description>
              <div className="sheet-actions">
                <Drawer.Close className="button">Close</Drawer.Close>
              </div>
            </Drawer.Content>
          </Drawer.Popup>
        </Drawer.Viewport>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Nested

A drawer opens another; data-nested-drawer-open and --nested-drawers style the ones behind.

DrawerNested.tsx
import { Drawer } from '@hamur/react/drawer'

export default function DrawerNestedExample() {
  return (
    <Drawer.Root>
      <Drawer.Trigger className="button">Open drawer stack</Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Backdrop className="backdrop drawer-backdrop" />
        <Drawer.Viewport className="sheet-viewport">
          <Drawer.Popup className="sheet sheet-stack">
            <div className="sheet-handle" />
            <Drawer.Content className="sheet-content">
              <Drawer.Title className="sheet-title">Account</Drawer.Title>
              <Drawer.Description className="sheet-description">
                Nested drawers can be styled to stack, while each drawer remains independently focus
                managed.
              </Drawer.Description>

              <div className="sheet-actions end">
                <div className="sheet-actions-left">
                  <Drawer.Root>
                    <Drawer.Trigger className="button">Security settings</Drawer.Trigger>
                    <Drawer.Portal>
                      <Drawer.Viewport className="sheet-viewport">
                        <Drawer.Popup className="sheet sheet-stack">
                          <div className="sheet-handle" />
                          <Drawer.Content className="sheet-content">
                            <Drawer.Title className="sheet-title">Security</Drawer.Title>
                            <Drawer.Description className="sheet-description">
                              Review sign-in activity and update your security preferences.
                            </Drawer.Description>

                            <ul className="sheet-list">
                              <li>Passkeys enabled</li>
                              <li>2FA via authenticator app</li>
                              <li>3 signed-in devices</li>
                            </ul>

                            <div className="sheet-actions end">
                              <div className="sheet-actions-left">
                                <Drawer.Root>
                                  <Drawer.Trigger className="button">
                                    Advanced options
                                  </Drawer.Trigger>
                                  <Drawer.Portal>
                                    <Drawer.Viewport className="sheet-viewport">
                                      <Drawer.Popup className="sheet sheet-stack">
                                        <div className="sheet-handle" />
                                        <Drawer.Content className="sheet-content">
                                          <Drawer.Title className="sheet-title">
                                            Advanced
                                          </Drawer.Title>
                                          <Drawer.Description className="sheet-description">
                                            This drawer is taller to demonstrate variable-height
                                            stacking.
                                          </Drawer.Description>

                                          <div className="sheet-field">
                                            <label htmlFor="device-name">Device name</label>
                                            <input
                                              id="device-name"
                                              className="input"
                                              defaultValue="Personal laptop"
                                            />
                                          </div>

                                          <div className="sheet-field">
                                            <label htmlFor="notes">Notes</label>
                                            <textarea
                                              id="notes"
                                              className="textarea"
                                              defaultValue="Rotate recovery codes and revoke older sessions."
                                              rows={3}
                                            />
                                          </div>

                                          <div className="sheet-actions end">
                                            <Drawer.Close className="button">Done</Drawer.Close>
                                          </div>
                                        </Drawer.Content>
                                      </Drawer.Popup>
                                    </Drawer.Viewport>
                                  </Drawer.Portal>
                                </Drawer.Root>
                              </div>

                              <Drawer.Close className="button">Close</Drawer.Close>
                            </div>
                          </Drawer.Content>
                        </Drawer.Popup>
                      </Drawer.Viewport>
                    </Drawer.Portal>
                  </Drawer.Root>
                </div>

                <Drawer.Close className="button">Close</Drawer.Close>
              </div>
            </Drawer.Content>
          </Drawer.Popup>
        </Drawer.Viewport>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Snap Points

snapPoints stops a bottom sheet at preset heights.

DrawerSnapPoints.tsx
import * as React from 'react'
import { Drawer } from '@hamur/react/drawer'

const TOP_MARGIN_REM = 1
const VISIBLE_SNAP_POINTS_REM = [30]

function toViewportSnapPoint(heightRem: number) {
  return `${heightRem + TOP_MARGIN_REM}rem`
}

const snapPoints = [...VISIBLE_SNAP_POINTS_REM.map(toViewportSnapPoint), 1]

export default function DrawerSnapPointsExample() {
  return (
    <Drawer.Root snapPoints={snapPoints}>
      <Drawer.Trigger className="button">Open snap drawer</Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Backdrop className="backdrop drawer-backdrop" />
        <Drawer.Viewport className="snap-viewport">
          <Drawer.Popup
            className="snap-sheet"
            style={{ '--top-margin': `${TOP_MARGIN_REM}rem` } as React.CSSProperties}
          >
            <div className="snap-drag">
              <div className="sheet-handle" />
              <Drawer.Title className="sheet-title">Snap points</Drawer.Title>
            </div>
            <Drawer.Content className="snap-scroll">
              <div className="sheet-content">
                <Drawer.Description className="sheet-description">
                  Drag the sheet to snap between a compact peek and a near full-height view.
                </Drawer.Description>
                <div className="snap-cards" aria-hidden>
                  {Array.from({ length: 20 }, (_, index) => (
                    <div className="snap-card" key={index} />
                  ))}
                </div>
                <div className="sheet-actions end">
                  <Drawer.Close className="button">Close</Drawer.Close>
                </div>
              </div>
            </Drawer.Content>
          </Drawer.Popup>
        </Drawer.Viewport>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Virtual Keyboard Aware

VirtualKeyboardProvider keeps the focused field visible above a software keyboard.

DrawerVirtualKeyboardAware.tsx
import { Drawer } from '@hamur/react/drawer'

const fields = [
  ['Name', 'Ada Lovelace'],
  ['Phone', '+1 (555) 123-4567'],
  ['Street address', '12 Computing Way'],
  ['Apartment', 'Unit 4B'],
  ['City', 'San Francisco'],
  ['Postal code', '94107'],
  ['Delivery window', 'After 6 PM'],
  ['Backup contact', 'Grace Hopper'],
]

export default function DrawerVirtualKeyboardAwareExample() {
  return (
    <Drawer.Root>
      <Drawer.Trigger className="button">Open keyboard-aware drawer</Drawer.Trigger>
      <Drawer.VirtualKeyboardProvider>
        <Drawer.Portal>
          <Drawer.Backdrop className="backdrop drawer-backdrop" />
          <Drawer.Viewport className="vk-viewport">
            <Drawer.Popup className="vk-sheet">
              <div className="vk-header">
                <div className="sheet-handle" />
                <div className="vk-header-actions">
                  <Drawer.Close className="button">Cancel</Drawer.Close>
                  <Drawer.Title className="sheet-title">Delivery details</Drawer.Title>
                  <Drawer.Close className="button">Save</Drawer.Close>
                </div>
              </div>

              <Drawer.Content className="vk-scroll">
                <div className="vk-form">
                  {fields.map(([label, placeholder]) => (
                    <label className="vk-field" key={label}>
                      <span>{label}</span>
                      <input className="input" placeholder={placeholder} type="text" />
                    </label>
                  ))}

                  <label className="vk-field">
                    <span>Instructions</span>
                    <textarea
                      className="textarea"
                      placeholder="Gate code, drop-off spot, or anything else the driver should know"
                    />
                  </label>
                </div>
              </Drawer.Content>

              <div className="vk-footer-slot">
                <div className="vk-footer">
                  <label className="vk-composer">
                    <span>Delivery note</span>
                    <input className="input" placeholder="Add a note for the driver" type="text" />
                  </label>
                </div>
              </div>
            </Drawer.Popup>
          </Drawer.Viewport>
        </Drawer.Portal>
      </Drawer.VirtualKeyboardProvider>
    </Drawer.Root>
  )
}

Indent Provider

Provider, Indent and IndentBackground scale the page down while a drawer is open.

DrawerIndentProvider.tsx
import * as React from 'react'
import { Drawer } from '@hamur/react/drawer'

export default function DrawerIndentProviderExample() {
  const [portalContainer, setPortalContainer] = React.useState<HTMLDivElement | null>(null)

  return (
    <Drawer.Provider>
      <div className="indent-root" ref={setPortalContainer}>
        <Drawer.IndentBackground className="indent-background" />
        <Drawer.Indent className="indent">
          <div className="indent-center">
            <Drawer.Root modal={false}>
              <Drawer.Trigger className="button">Open drawer</Drawer.Trigger>
              <Drawer.Portal container={portalContainer}>
                <Drawer.Backdrop className="backdrop drawer-backdrop contained" />
                <Drawer.Viewport className="sheet-viewport contained">
                  <Drawer.Popup className="sheet">
                    <div className="sheet-handle" />
                    <Drawer.Content className="sheet-content">
                      <Drawer.Title className="sheet-title">Notifications</Drawer.Title>
                      <Drawer.Description className="sheet-description">
                        You are all caught up. Good job!
                      </Drawer.Description>
                      <div className="sheet-actions">
                        <Drawer.Close className="button">Close</Drawer.Close>
                      </div>
                    </Drawer.Content>
                  </Drawer.Popup>
                </Drawer.Viewport>
              </Drawer.Portal>
            </Drawer.Root>
          </div>
        </Drawer.Indent>
      </div>
    </Drawer.Provider>
  )
}

Non Modal

modal off and disablePointerDismissal keep the page usable and the drawer open.

DrawerNonModal.tsx
import { Drawer } from '@hamur/react/drawer'

export default function DrawerNonModalExample() {
  return (
    <Drawer.Root swipeDirection="right" modal={false} disablePointerDismissal>
      <Drawer.Trigger className="button">Open non-modal drawer</Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Viewport className="drawer-viewport">
          <Drawer.Popup className="drawer">
            <Drawer.Content>
              <Drawer.Title className="dialog-title">Non-modal drawer</Drawer.Title>
              <Drawer.Description className="muted">
                This drawer does not trap focus and ignores outside clicks. Use the close button or
                swipe to dismiss it.
              </Drawer.Description>
              <div className="actions">
                <Drawer.Close className="button">Close</Drawer.Close>
              </div>
            </Drawer.Content>
          </Drawer.Popup>
        </Drawer.Viewport>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Swipe Area

SwipeArea along the edge opens the drawer with a swipe.

DrawerSwipeArea.tsx
import * as React from 'react'
import { Drawer } from '@hamur/react/drawer'

export default function DrawerSwipeAreaExample() {
  const [portalContainer, setPortalContainer] = React.useState<HTMLDivElement | null>(null)

  return (
    <div className="swipe-root" ref={setPortalContainer}>
      <Drawer.Root swipeDirection="right" modal={false}>
        <Drawer.SwipeArea className="swipe-area">
          <span className="swipe-label">Swipe here</span>
        </Drawer.SwipeArea>
        <div className="swipe-center">
          <p className="swipe-hint">Swipe from the right edge to open the drawer.</p>
        </div>
        <Drawer.Portal container={portalContainer}>
          <Drawer.Backdrop className="backdrop drawer-backdrop contained" />
          <Drawer.Viewport className="drawer-viewport contained">
            <Drawer.Popup className="drawer">
              <Drawer.Content>
                <Drawer.Title className="dialog-title">Library</Drawer.Title>
                <Drawer.Description className="muted">
                  Swipe from the edge whenever you want to jump back into your playlists.
                </Drawer.Description>
                <div className="actions">
                  <Drawer.Close className="button">Close</Drawer.Close>
                </div>
              </Drawer.Content>
            </Drawer.Popup>
          </Drawer.Viewport>
        </Drawer.Portal>
      </Drawer.Root>
    </div>
  )
}

API reference

31 props across 15 parts, in React syntax. className, style, render are listed once, under Shared props.

BackdropDrawer.Backdrop

An overlay displayed beneath the popup. Renders a <div>.

Backdrop props
PropTypeDefault
forceRenderWhether the backdrop is forced to render even when nested.booleanfalse
Backdrop data attributes
Data attributeDescription
data-openPresent when the drawer is open.
data-closedPresent when the drawer is closed.
data-starting-stylePresent when the drawer begins animating in.
data-ending-stylePresent when the drawer is animating out.
Backdrop CSS variables
CSS variableDescription
--drawer-swipe-progressThe swipe progress of the drawer gesture.

CloseDrawer.Close

A button that closes the drawer. Renders a <button>.

Close props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
Close data attributes
Data attributeDescription
data-disabledPresent when the button is disabled.

ContentDrawer.Content

A container for the drawer contents. Renders a <div>.

DescriptionDrawer.Description

A paragraph with additional information about the drawer. Renders a <p>.

IndentDrawer.Indent

A wrapper element intended to contain your app's main UI. Applies data-active when any drawer within the nearest <Drawer.Provider> is open. Renders a <div>.

IndentBackgroundDrawer.IndentBackground

An element placed before <Drawer.Indent> to render a background layer that can be styled based on whether any drawer is open. Renders a <div>.

PopupDrawer.Popup

A container for the drawer contents. Renders a <div>.

Popup props
PropTypeDefault
initialFocusDetermines the element to focus when the drawer is opened.boolean | React.RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | void | HTMLElement | null)—
finalFocusDetermines the element to focus when the drawer is closed.boolean | React.RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null)—
Popup data attributes
Data attributeDescription
data-openPresent when the drawer is open.
data-closedPresent when the drawer is closed.
data-starting-stylePresent when the drawer begins animating in.
data-ending-stylePresent when the drawer is animating out.
data-expandedPresent when the drawer is at the expanded (full-height) snap point.
data-nested-drawer-openPresent when a nested drawer is open.
data-nested-drawer-swipingPresent when a nested drawer is being swiped.
data-swipe-dismissPresent when the drawer is dismissed by swiping.
data-swipe-directionIndicates the swipe direction.
data-swipingPresent when the drawer is being swiped.
Popup CSS variables
CSS variableDescription
--nested-drawersThe number of nested drawers that are currently open.
--drawer-heightThe height of the drawer popup.
--drawer-frontmost-heightThe height of the frontmost open drawer in the current nested drawer stack.
--drawer-swipe-movement-xThe swipe movement on the X axis.
--drawer-swipe-movement-yThe swipe movement on the Y axis.
--drawer-snap-point-offsetThe snap point offset used for translating the drawer.
--drawer-swipe-strengthA scalar (0.1-1) used to scale the swipe release transition duration in CSS.

PortalDrawer.Portal

A portal element that moves the popup to a different part of the DOM. By default, the portal element is appended to <body>. Renders a <div>.

Portal props
PropTypeDefault
containerA parent element to render the portal element into.HTMLElement | ShadowRoot | React.RefObject<HTMLElement | ShadowRoot | null> | null—
keepMountedWhether to keep the portal mounted in the DOM while the popup is hidden.booleanfalse

ProviderDrawer.Provider

Provides a shared context for coordinating global Drawer UI, such as indent/background effects based on whether any Drawer is open. Doesn’t render its own element.

Provider props
PropTypeDefault
childrenReact.ReactNode—

RootDrawer.Root

Groups all parts of the drawer. Doesn’t render its own element.

Root props
PropTypeDefault
defaultOpenWhether the drawer is initially open.booleanfalse
openWhether the drawer is currently open.boolean—
onOpenChangeEvent handler called when the drawer is opened or closed.((open: boolean, eventDetails: DrawerRootChangeEventDetails) => void)—
snapPointsSnap points used to position the drawer.DrawerSnapPoint[]—
defaultSnapPointThe initial snap point value when uncontrolled.DrawerSnapPoint | null—
snapPointThe currently active snap point.DrawerSnapPoint | null—
onSnapPointChangeCallback fired when the snap point changes.((snapPoint: DrawerSnapPoint | null, eventDetails: DrawerRootSnapPointChangeEventDetails) => void)—
actionsRefA ref to imperative actions.React.RefObject<DrawerRootActions | null>—
defaultTriggerIdID of the trigger that the drawer is associated with.string | null—
disablePointerDismissalWhether to prevent the drawer from closing on outside presses.booleanfalse
handleA handle to associate the drawer with a trigger.DrawerHandle<Payload>—
modalDetermines if the drawer enters a modal state when open.boolean | "trap-focus"true
onOpenChangeCompleteEvent handler called after any animations complete when the drawer is opened or closed.((open: boolean) => void)—
snapToSequentialPointsDisables velocity-based snap skipping so drag distance determines the next snap point.booleanfalse
swipeDirectionThe swipe direction used to dismiss the drawer.SwipeDirection'down'
triggerIdID of the trigger that the drawer is associated with.string | null—
childrenThe content of the drawer.React.ReactNode | PayloadChildRenderFunction<Payload>—

SwipeAreaDrawer.SwipeArea

An invisible area that listens for swipe gestures to open the drawer. Renders a <div>.

SwipeArea props
PropTypeDefault
swipeDirectionThe swipe direction that opens the drawer.SwipeDirection—
disabledWhether the swipe area is disabled.booleanfalse
SwipeArea data attributes
Data attributeDescription
data-openPresent when the drawer is open.
data-closedPresent when the drawer is closed.
data-disabledPresent when the swipe area is disabled.
data-swipe-directionIndicates the swipe direction.
data-swipingPresent when the drawer is being swiped.

TitleDrawer.Title

A heading that labels the drawer. Renders a <h2>.

TriggerDrawer.Trigger

A button that opens the drawer. Renders a <button>.

Trigger props
PropTypeDefault
handleA handle to associate the trigger with a drawer.DrawerHandle<Payload>—
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
payloadA payload to pass to the drawer when it is opened.Payload—
idID of the trigger.string—
Trigger data attributes
Data attributeDescription
data-disabledPresent when the trigger is disabled.
data-popup-openPresent when the corresponding dialog is open.

ViewportDrawer.Viewport

A positioning container for the drawer popup that can be made scrollable. Renders a <div>.

Viewport data attributes
Data attributeDescription
data-openPresent when the drawer is open.
data-closedPresent when the drawer is closed.
data-starting-stylePresent when the drawer begins animating in.
data-ending-stylePresent when the drawer is animating out.
data-nestedPresent when the drawer is nested within another drawer.
Viewport CSS variables
CSS variableDescription
--drawer-keyboard-insetThe software keyboard inset, measured from the bottom edge of the layout viewport. Present only when the drawer is wrapped in Drawer.VirtualKeyboardProvider.

VirtualKeyboardProviderDrawer.VirtualKeyboardProvider

Provides keyboard-aware focus and scroll handling for bottom-sheet drawers with form fields.

VirtualKeyboardProvider props
PropTypeDefault
childrenReact.ReactNode—

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Drawer takes Dialog's keys: Escape closes it, and Tab stays inside the popup while it is modal.

Accessibility

What the parts set
PartSetsSo that
Triggeraria-haspopup="dialog", aria-expanded, aria-controlsa screen reader says the button opens a dialog and whether it is open
Popuprole="dialog", aria-labelledby to Title, aria-describedby to Descriptionthe drawer is announced as a dialog with its heading and description
Popuparia-hidden="true" on the rest of the page while modal is ona screen reader stays inside the drawer
Backdroprole="presentation"the overlay is not read
SwipeArearole="presentation", aria-hidden="true"the invisible gesture area is not read or reached

Focus order

  1. Opening moves focus to Popup, or to initialFocus.
  2. Tab and Shift+Tab stay inside the popup while it is modal; with modal off, focus can leave.
  3. Closing returns focus to the Trigger that opened it, or to finalFocus.