overview

Accessible drawer behavior, none of the visual system.

hiraki handles semantics, gestures, snap points, dismissal, and focus management. Your product keeps full control of the surface — color, radius, type, spacing, and motion.

It ships zero runtime dependencies: no Radix, no Framer Motion, ~10 KB gzipped. Behavior lives in the library; everything you can see stays in your app.

There is no CSS file to import and no class names to override. You style the primitives with Tailwind, CSS variables, or the style prop — and the same component can look calm and product-like in one place and loud and editorial in another.

install

Add the package

Install with your package manager of choice, then compose the primitives directly in your UI.

pnpm
pnpm add hiraki
npm
npm install hiraki

quick start

Structure first, styling second

Start from the structural parts. Once the behavior works, attach your classes and tokens.

quick-start.tsx
import { Drawer } from 'hiraki'

export function Example() {
  return (
    <Drawer.Root>
      <Drawer.Trigger className="trigger">Open</Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Overlay className="overlay" />
        <Drawer.Content className="content">
          <Drawer.Handle className="handle" />
          <Drawer.Title className="title">Edit profile</Drawer.Title>
          <Drawer.Description className="description">
            Build your own design around the primitives.
          </Drawer.Description>
          <Drawer.Close className="close">Close</Drawer.Close>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Open it to feel the gesture, velocity, and dismissal in a minimal product skin:

styling

One primitive, any product voice

The behavior layer stays stable while your team owns the visual direction. Both drawers below are the same primitives — only the class names differ.

styling.tsx
// Same primitive, swap the classes.
<Drawer.Content className="rounded-t-2xl bg-white text-zinc-900">
  {/* soft product sheet */}
</Drawer.Content>

<Drawer.Content className="rounded-none border-l-2 border-white bg-neutral-950">
  {/* square editorial panel */}
</Drawer.Content>

behavior

Behavior stays consistent while the style changes

Direction, snap points, dismissal, and drag physics come from the primitive layer.

  • Directions — bottom, top, left, and right, each with the correct gesture axis.
  • Snap points — pixel values, percentages, and the content keyword.
  • Accessibility — dialog semantics, focus trap, Escape, overlay dismissal, scroll lock.
  • Control model — open state and active snap point can both be controlled from the parent.
  • Handle — an optional affordance; place it where the direction makes sense, or hide it.
behavior.tsx
<Drawer.Root
  snapPoints={['25%', '55%', '90%']}
  activeSnapPoint={snap}
  onSnapPointChange={setSnap}
  direction="bottom"
  dismissible
>
  <Drawer.Portal>
    <Drawer.Overlay className="overlay" />
    <Drawer.Content className="content">
      <Drawer.Handle visible />
      <Drawer.Title>Filters</Drawer.Title>
      <Drawer.SnapIndicator />
    </Drawer.Content>
  </Drawer.Portal>
</Drawer.Root>

api

Compound API

Every prop, type, and default. All parts also forward standard HTML attributes — className, style, data-*, and event handlers.

Drawer.Root

open

boolean

Controlled open state. Pair with onOpenChange.

defaultOpenfalse

boolean

Uncontrolled initial open state.

onOpenChange

(open: boolean) => void

Called whenever the drawer opens or closes.

direction"bottom"

"top" | "bottom" | "left" | "right"

Which edge the drawer slides from. Determines gesture axis and transform direction.

variant"default"

"default" | "floating" | "sheet" | "fullscreen" | "nested" | "stack"

Visual mode. Affects border radius, margin, and animation preset.

modaltrue

boolean

Locks page scroll and traps focus inside the drawer while open.

dismissibletrue

boolean

Whether overlay click and Escape key close the drawer.

snapPoints[]

SnapPoint[]

Snap positions. number = px from edge, "25%" = fraction of viewport, "content" = content height.

activeSnapPoint

number

Controlled snap index. 0 is the smallest point, length - 1 is the largest.

onSnapPointChange

(index: number) => void

Called when the active snap index changes after a drag.

closeThreshold0.5

number

Fraction of the drawer height that must be dragged before auto-close triggers on release.

rubberBandtrue

boolean

Elastic resistance when dragging past the open or closed extent.

inertiatrue

boolean

Uses release velocity to project the landing snap point. Fast flick = skip a snap.

shouldScaleBackgroundfalse

boolean

Scales the page element behind the drawer down when open. Add data-hiraki-background to the element to scale.

onDragStart

(data: GestureCallbackData) => void

Fires once when a drag gesture is confirmed (after axis lock).

onDrag

(data: GestureCallbackData) => void

Fires on every pointer move during a drag.

onDragEnd

(data: GestureCallbackData) => void

Fires when the pointer is released, with final velocity and translate.

Drawer.Portal

containerdocument.body

HTMLElement | null

DOM node to portal into. Defaults to document.body.

Drawer.Trigger

asChildfalse

boolean

Merges props onto the child element instead of rendering a button.

Drawer.Close

asChildfalse

boolean

Merges props onto the child element instead of rendering a button.

Drawer.Handle

visibletrue

boolean

Controls whether the handle pill is rendered.

handleOnlyfalse

boolean

When true, drag gestures only start from the handle, not the full content area.

Drawer.Content

...HTMLAttributes

React.HTMLAttributes<HTMLDivElement>

All standard div props forwarded. Use className to apply your own styles.

Drawer.Overlay

...HTMLAttributes

React.HTMLAttributes<HTMLDivElement>

All standard div props forwarded. Opacity tracks drag progress via direct DOM mutation.