A Tailwind CSS v4 Drawer That Does Not Fight the Browser
You wake up at 3:00 AM to a PagerDuty alert: users report the settings panel freezing their dashboards. DevTools shows 200ms frames during open. The culprit is a JavaScript animation loop measuring the drawer, writing its width, then triggering synchronous layout recalculations on every tick. By morning, you've also discovered keyboard users can't escape the overlay, and screen readers still navigate the page underneath.
These aren't separate bugs. They expose a missing architectural boundary: application code trying to own presentation, animation scheduling, and modal behavior simultaneously.
As an external adopter of tailwindlabs/tailwindcss, I treat Tailwind v4 as the presentation layer, not the interaction engine. Radix Dialog owns modal semantics and focus management. CSS owns motion. React supplies content and state.
The result is a reusable drawer with fewer synchronization points and no JavaScript animation loop.
Why the tutorial version breaks
A common implementation measures the drawer, then repeatedly writes its width during opening. Width changes invalidate layout; subsequent geometry reads can force synchronous layout before the browser would otherwise perform it. On a busy dashboard with live charts and data tables, that read-write cycle becomes visible as dropped frames.
The accessibility failure often arrives in the same component. A hand-written Tab handler wraps focus between two buttons, but misses conditional fields, validation messages, or nested popovers. Meanwhile, the background remains available to assistive technology, allowing users to interact with obscured content.
Moving the drawer with transform avoids changing surrounding layout during animation. It does not guarantee free rendering, but it removes width interpolation from the critical path. Delegating modal behavior to Radix avoids maintaining an incomplete focus model.
Wire Tailwind into the build
In an existing React/TypeScript Vite application, install tailwindcss@4, @tailwindcss/vite@4, and @radix-ui/react-dialog. Keep the Vite plugin and framework versions compatible with the application.
This is the complete build configuration; Tailwind v4 needs no JavaScript theme configuration for this example.
// vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [react(), tailwindcss()], }); Keep utility classes literal so Tailwind can discover them. Constructing fragments such as translate-x-${offset} is unnecessary here and makes generated styles harder to reason about.
Keep interaction semantics in the primitive
The component deliberately exposes content rather than individual form fields. Forms retain responsibility for validation and persistence; the drawer only provides a modal shell.
// SettingsDrawer.tsx import type { ReactNode } from "react"; import * as Dialog from "@radix-ui/react-dialog"; import "./app.css"; type SettingsDrawerProps = { title: string; description: string; children: ReactNode; }; export function SettingsDrawer({ title, description, children, }: SettingsDrawerProps) { return ( <Dialog.Root> <Dialog.Trigger asChild> <button type="button" className="rounded-md border px-4 py-2 focus-visible:outline-2 focus-visible:outline-offset-4" > Open settings </button> </Dialog.Trigger> <Dialog.Portal> <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" /> <Dialog.Content className="drawer fixed inset-y-0 right-0 z-50 w-full max-w-lg overflow-y-auto bg-white text-slate-950 shadow-xl focus-visible:outline-2 focus-visible:outline-blue-600" > <div className="drawer-container"> <div className="drawer-inner space-y-6 p-4"> <header className="space-y-2"> <Dialog.Title className="text-xl font-semibold"> {title} </Dialog.Title> <Dialog.Description className="text-sm text-slate-600"> {description} </Dialog.Description> </header> {children} <footer className="drawer-actions flex justify-end"> <Dialog.Close asChild> <button type="button" aria-label="Close settings" className="rounded-md bg-slate-950 px-4 py-2 text-white focus-visible:outline-2 focus-visible:outline-offset-4" > Close </button> </Dialog.Close> </footer> </div> </div> </Dialog.Content> </Dialog.Portal> </Dialog.Root> ); } Radix supplies the dialog role, modal ARIA state, and title/description associations. It also handles focus containment, Escape dismissal, and returning focus to the trigger. Adding competing aria-hidden logic or a second focus trap would undermine that ownership.
Animate presentation, not geometry
The container query adjusts the drawer's internal spacing according to its available width, not the viewport. The named container sits above the elements it styles: a container cannot query its own size to restyle itself.
/* app.css */ @import "tailwindcss"; .drawer-container { container: settings / inline-size; } .drawer[data-state="open"] { animation: drawer-enter 180ms ease-out both; } .drawer[data-state="closed"] { animation: drawer-exit 140ms ease-in both; } @keyframes drawer-enter { from { transform: translateX(100%); } to { transform: translateX(0); } } @keyframes drawer-exit { from { transform: translateX(0); } to { transform: translateX(100%); } } @container settings (min-width: 28rem) { .drawer-inner { padding: 2rem; } .drawer-actions { border-top: 1px solid var(--color-slate-200); padding-top: 1rem; } } @media (prefers-reduced-motion: reduce) { .drawer[data-state] { animation: none; } } Radix can retain content during its CSS exit animation; avoid adding a competing timeout-based unmount. Reduced-motion users get an immediate transition without application-side branching.
The production boundary
This architecture adds a primitive dependency, but removes bespoke modal infrastructure. Tailwind remains responsible for visual composition; container queries handle local responsiveness; Radix owns interaction semantics.
The trade-off is intentional: closing currently discards state and saves nothing. Applications with unsaved changes need an explicit dismissal policy, usually through controlled dialog state and an onInteractOutside handler that prevents closure until the user confirms or saves.
Before shipping, test keyboard entry, reverse tabbing, Escape, focus restoration, zoom, and reduced motion. Include a screen reader pass and any portalled controls used inside the drawer. Primitive defaults are a strong foundation, not proof that the surrounding application is accessible.
The important optimization is not a shorter class string. It is eliminating the JavaScript layout loop and the home-grown focus trap from the same interaction boundary.
What's your production drawer story? Have you traced frame drops back to width measurements, or discovered focus escaping your modal at 2:00 AM? What primitives or patterns finally stabilized your overlay architecture?
