Build · Components
A drawer that follows the finger

A drawer that follows the finger

Emil Kowalski’s second library is , “A drawer component for React.” Building a drawer component opens with the motive: “Using Apple’s Sheet component on iOS feels natural, I wanted to create the same experience, but for the web.” He prefers a drawer to a modal on mobile. Vercel’s existing one lacked drag-to-dismiss and had input problems, so he built a new one and open-sourced it for the feedback.

Borrowed anatomy, written feel#

Vaul sits on Radix’s Dialog primitive, which “ensures the component is accessible, handles focus management etc.” The API mirrors Radix: Drawer.Root, Trigger, Portal, Content, Overlay, plus Handle, Title, Description, and Close on the API page.

What Emil wrote is the feel, starting with one line of CSS. The curve “closely matches the one used in iOS; it’s from the Ionic Framework,” and 500 ms “is also supposed to mimic iOS’s Sheet.” His thesis: “Using the right easing and duration makes a big difference in terms of how this, and any other component, feels.”

Note that 500 ms exceeds the under-300 ms rule he gives elsewhere; a drawer is large and imitates a system component with its own timing.

CSSEmil Kowalski, Building a drawer component
.drawer {  transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1);}

Dragging without dropping frames#

“Not losing frames while dragging is a good start, but I already failed there.” Past roughly 20 list items, dragging lagged with no re-renders to blame. Each update wrote a CSS variable feeding translateY, and CSS variables are inheritable, so every child recalculated style. Writing the transform directly on the element fixed it, a quick fix in retrospect that took hours.

TypeScriptEmil Kowalski, Building a drawer component
const style = {  "--swipe-amount": `${draggedDistance}px`,};const style = {  transform: `translateY(${draggedDistance}px)`,};

Then momentum, so a flick closes the drawer, and damping at the top, where it moves less the further you pull: “things in real life don’t suddenly stop, they slow down first.” A shouldDrag function refuses the drag unless content is scrolled to the top, as on iOS, and since fast scrolling overshoots, a 100 ms timeout after reaching the top forbids dragging.

Touches after the first are ignored until release so a second finger does not make the drawer jump. “They are ‘invisible’ because they align with users’ inherent expectations.”

Reaching outside the sheet#

scaleBackground makes the body look like another sheet: Vaul finds [vaul-drawer-wrapper] and applies a transform and border radius that follow drag progress, so 40% down sets the radius to 60% of its maximum. are a fraction of the viewport or fixed pixels, snapped to the closest on release and momentum-based; fixed values keep an input sticking out evenly on every device. Apple Maps is the model.

Inputs get the Visual Viewport API: Vaul listens for visualViewport resize and sets the drawer’s height and bottom from the new height, so it sits above the keyboard and stays scrollable. The cost is a slight delay, since the event fires once the keyboard is fully visible.

One idea did not ship: dimming Safari’s theme-color bar with the overlay. The meta tag cannot transition or take semi-transparent colors, so he computed the opaque equivalent, built 50 interpolated colors, and updated the tag every 10 ms to match the 500 ms transition, eased with the same bezier via bezier-easing. It stays out of Vaul because it drifts when frames drop.

Drag the handle on the figure below and let go. The drawer settles on the nearest snap point using Vaul’s own curve and timing, and slows the further you pull it past fully open. Try the Scale background switch too, to see the page underneath shrink and gain a radius as the sheet moves, matching what Emil describes above.

Offset
35%
Velocity
0px/s
Bar step
19/50
theme-color bar
Snap target
peek 35%
Adjust
drag down 40% = radius 60% of max
Figure 2. A drawer with snap points. Drag the sheet by its handle and let go. It settles on the nearest snap point with Vaul's transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1). Pull up past open and the drag damps, moving less the further you go. The strip at the very top is the theme-color bar, stepped through 50 colours every 10 ms so it keeps pace with the 500 ms transition. The curve, duration, bar stepping, and the 40 to 60 radius mapping are Emil's; the snap points and damping factor here are illustrative.Constants from the source

His last point is testing: a real phone on a cable with Safari’s devtools, or the Xcode Simulator, never a shrunken desktop window. The README now calls the repo unmaintained; the decisions stand. Vaul is a short list of numbers chosen to match a reference.