A drawer that follows the finger
Emil Kowalski’s second library is Vaul, which his home page describes in four words: “A drawer component for React.” The motive is in the first paragraph of Building a drawer component: “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 for a more native feel. Vercel already had one, but it lacked drag-to-dismiss and had problems with inputs, so he built a new one from scratch and open-sourced it, reasoning that more users means more feedback and a better component.
The structure is borrowed on purpose. Vaul is built on Radix’s Dialog primitive, which, in his words, “ensures the component is accessible, handles focus management etc.” The API deliberately mirrors Radix so it feels familiar: Drawer.Root, Drawer.Trigger, Drawer.Portal, Drawer.Content, Drawer.Overlay. The API page adds Handle, Title, Description, and Close, and describes the Overlay as a layer that covers the inert portion of the view while the drawer is open. Accessibility, in other words, was not written by hand; it was inherited from a primitive whose job that is.
What Emil did write is the feel, and the feel starts with one line of CSS: transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1). The curve “closely matches the one used in iOS; it’s from the Ionic Framework,” and the 500 ms duration “is also supposed to mimic iOS’s Sheet.” His comment on this is the thesis of the whole post: “Using the right easing and duration makes a big difference in terms of how this, and any other component, feels.” Note that 500 ms is longer than the under-300 ms rule he gives elsewhere for UI animation. A drawer is large, it travels the height of the screen, and it is imitating a system component with its own timing.
Then the drag. “Not losing frames while dragging is a good start, but I already failed there.” Once the drawer held more than roughly 20 list items, dragging got laggy, with no re-renders to blame. The cause was that each drag update wrote a CSS variable that fed translateY, and CSS variables are inheritable, so changing one recalculates style for every child. More items, more expensive. Writing the transform directly on the element fixed it; he says it looks like a quick fix in retrospect and took hours to find. On top of that he added momentum-based dragging, so a flick closes the drawer without dragging to a point, and damping at the top: drag upward when the drawer is already fully open and it moves less the further you pull, because “things in real life don’t suddenly stop, they slow down first.”
Scrolling inside a drawer that can also be dragged is, as he puts it, tricky. A shouldDrag function refuses the drag unless the content is scrolled to the top, which is how native iOS drawers behave. But people scroll fast on phones, so you can overshoot the top, begin dragging by accident, and close the drawer because the velocity is high. His guard is a 100 ms timeout after you reach the top during which dragging is not allowed. Multi-touch got a similar small rule: touches after the first are ignored until release, so adding a second finger does not make the drawer jump. He frames these as invisible details in the sense this book uses the phrase: “They are ‘invisible’ because they align with users’ inherent expectations.”
Two features reach outside the drawer. A scaleBackground prop creates the illusion that the page body is itself another sheet behind the open one: Vaul finds the [vaul-drawer-wrapper] element and applies a transform and a border radius. While dragging, those values follow the drag progress rather than a clock, so dragging the drawer down by 40% changes the border radius to 60% of its maximum. The second is snap points: checkpoints expressed as a fraction of the viewport or a fixed pixel value, with the drawer snapping to the closest one on release. Fixed pixel values are useful when an input should stick out by the same amount on every device. Snapping is momentum-based too: flick hard enough and the drawer skips points or closes. He points to Apple Maps as the model.
Inputs were the problem the old Vercel drawer had, and the fix is the Visual Viewport API. When the keyboard appears the browser scrolls to keep the focused input visible, pushing the drawer up and hiding content. Vaul disables that and listens for visualViewport resize instead, setting the drawer’s height and bottom from the new viewport height, which also leaves the drawer fully scrollable above the keyboard. The cost is a slight delay, because the event fires only after the keyboard is fully visible. One idea did not ship at all. He wanted Safari’s theme-color bar to dim with the overlay, but the meta tag cannot transition and cannot take semi-transparent colors. So he computed the opaque equivalent of the overlay on the background, built an array of 50 interpolated colors, and updated the meta tag every 10 ms, since 10 times 50 matches the 500 ms transition, easing the interpolation with the same bezier through the bezier-easing library and switching to linear interpolation while dragging. It stays out of Vaul because it falls out of sync when frames drop.
His last practical point is about how you test. You cannot shrink a desktop window and call it a phone. He used a real phone on a cable, with Safari’s devtools on the laptop, or the Xcode Simulator as a near-exact replica. Kathryn Gonzalez reaches the identical conclusion from the opposite direction in the modal section later in this chapter. The pattern to take away is how much of Vaul is a short list of numbers chosen to match a reference: one curve, one duration, one timeout, one damping rule. Pick the reference first, and the numbers have something to be right about.