A drawer that follows the finger
Emil Kowalski’s second library is Vaul, “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.
.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.
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. Snap points 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.
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.