Family Drawer
InteractiveA bottom sheet that springs up from the edge and morphs its height through a family of steps: swap the children (with an optional contentKey) and it measures the incoming content, springs the panel to fit, and cross-fades the step swap, while a grabber handle drags the sheet down to dismiss (velocity-aware). Fully modal — focus trap and return, body scroll lock, Escape and backdrop to close; under prefers-reduced-motion the height morph, slide-up, and drag physics are dropped for an instant, legible show/hide.
Inspired by Emil Kowalski / Vaul-style drawer (upstream of Skiper); pattern — independently built, not affiliated. Attribution policy
Installation
npx shadcn@latest add https://parable-three.vercel.app/r/family-drawer.jsonRuns the shadcn CLI — the component source is copied intocomponents/parable/family-drawer.tsx. You own it from there.
Usage
The exact code rendering the preview above.
<div className="flex w-full flex-col items-center gap-6 px-6 py-8">
<div className="flex items-center gap-1.5" aria-hidden>
<span className="h-1.5 w-6 rounded-full bg-violet-400" />
<span className="h-1.5 w-6 rounded-full bg-white/20" />
<span className="h-1.5 w-6 rounded-full bg-white/20" />
</div>
<FamilyDrawer
triggerLabel={
<>
<Wallet className="size-4" aria-hidden />
Add payment method
</>
}
title="Add a card"
description="Enter details, review, then confirm — the sheet morphs between steps."
contentKey="step-1"
>
<div className="space-y-2.5 text-sm text-white/70">
<div className="rounded-xl bg-white/[0.04] px-3 py-2.5 ring-1 ring-white/10">
Card number
</div>
<div className="grid grid-cols-2 gap-2.5">
<div className="rounded-xl bg-white/[0.04] px-3 py-2.5 ring-1 ring-white/10">
Expiry
</div>
<div className="rounded-xl bg-white/[0.04] px-3 py-2.5 ring-1 ring-white/10">
CVC
</div>
</div>
<button
type="button"
className="mt-1 w-full rounded-xl bg-violet-500 px-4 py-2.5 text-sm font-semibold text-white transition-[filter] hover:brightness-110"
>
Continue
</button>
</div>
</FamilyDrawer>
<div className="flex items-center gap-2 text-xs text-white/40">
<Layers className="size-3.5" aria-hidden />
Height morphs as steps change
</div>
</div>Props
FamilyDrawerProps extends Omit< React.HTMLAttributes<HTMLDivElement>, | "children" | "title" | "onAnimationStart" | "onAnimationEnd" | "onAnimationIteration" | "onDrag" | "onDragStart" | "onDragEnd" >
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state. Pair with `onOpenChange`. |
| defaultOpen | boolean | false | Uncontrolled initial open state. |
| onOpenChange | (open: boolean) => void | — | Fires with the next open state on trigger, backdrop, Escape, or drag-dismiss. |
| triggerLabel | React.ReactNode | — | Renders a built-in trigger button with this label. Omit to open via `open`. |
| children* | React.ReactNode | — | Drawer body. Swap it to move between steps — the height springs to the new content. Change `contentKey` alongside it to cross-fade the swap. |
| contentKey | React.Key | — | Identity of the current step. When it changes, the outgoing content cross-fades out as the incoming content fades in and the height morphs. Leave undefined to update children in place (height still morphs). |
| title | React.ReactNode | — | Accessible + visible heading. When set, labels the dialog via `aria-labelledby`. |
| description | React.ReactNode | — | Secondary line under the title; describes the dialog via `aria-describedby`. |
| accent | string | "#8b5cf6" | Accent hex — trigger, grabber, focus ring. Mirrors the site `--pb-violet` token. |
| background | string | "#0f0f10" | Panel face colour. Mirrors the site `--pb-ink` token. |
| dismissThreshold | number | 120 | Downward drag distance (px) past which release dismisses the drawer. |
npm dependencies
- motion