EmpireUI
Get Pro
← Blog7 min read#react#drawer#side-sheet

Side Sheet Drawer in React: Slide-In Panels with Animation

Build a slide-in side sheet drawer in React with smooth CSS animations, focus trapping, and Tailwind v4 — no third-party library required. Step-by-step guide.

Code editor showing a React component with slide-in panel animation on a dark background

What Is a Side Sheet Drawer and Why Build Your Own?

Honestly, most third-party drawer libraries are overkill. You pull in 40 kB of headless UI or Radix just to get a <div> that slides in from the right. That's fine if you already have those deps, but if you don't, rolling your own side sheet drawer in React is maybe 80 lines of TypeScript and costs you nothing at runtime.

A side sheet (also called a drawer, slide-over, or panel) is an overlay panel that enters from an edge of the viewport — typically the right — and sits on top of the page content. It's everywhere: filter sidebars in ecommerce, detail views in dashboards, cart previews, settings panels. Users recognise the pattern instantly because native mobile apps have trained them to.

The real reason to build it yourself is control. You get to decide the animation curve, the overlay opacity, what happens on Escape, whether the body scrolls behind it, and exactly which CSS properties trigger the transition. No surprise breaking changes between package versions either.

The Core CSS Animation: Translate, Transition, Done

The mechanic is simple. The drawer panel starts translated off-screen (translateX(100%) for a right-side sheet) and then transitions to translateX(0) when it's open. That's it. The rest is polish.

What you want to avoid is animating width or display. Both cause layout recalculations on every frame. transform: translateX() runs entirely on the GPU compositor thread — silky smooth even on mobile. Pair it with will-change: transform on the drawer element if you want the browser to promote it to its own layer ahead of time, though in 2026 browsers are pretty smart about this already.

Here's a minimal but production-ready CSS baseline you can use with or without Tailwind: ``css /* drawer.css */ .drawer { position: fixed; top: 0; right: 0; height: 100dvh; width: 420px; max-width: 90vw; background: #0f0f0f; border-left: 1px solid rgba(255, 255, 255, 0.08); box-shadow: -8px 0 32px rgba(0, 0, 0, 0.45); transform: translateX(100%); transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1); z-index: 50; will-change: transform; overflow-y: auto; } .drawer[data-open='true'] { transform: translateX(0); } .drawer-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0); transition: background 280ms ease; z-index: 49; pointer-events: none; } .drawer-overlay[data-open='true'] { background: rgba(0, 0, 0, 0.55); pointer-events: auto; } ``

Notice the easing: cubic-bezier(0.4, 0, 0.2, 1) is Material Design's standard easing. It accelerates fast out of the closed position and decelerates gently into the open one, which feels intentional rather than mechanical. You can swap it for ease-out if you want something snappier.

Building the React Side Sheet Component

Let's wire up the component. We need: an isOpen boolean, a way to close it (clicking the overlay, pressing Escape), and proper ARIA attributes so screen readers understand what's happening.

// SideSheet.tsx
import { useEffect, useRef } from 'react';

interface SideSheetProps {
  isOpen: boolean;
  onClose: () => void;
  title?: string;
  children: React.ReactNode;
  width?: string; // e.g. '480px'
}

export function SideSheet({
  isOpen,
  onClose,
  title,
  children,
  width = '420px',
}: SideSheetProps) {
  const drawerRef = useRef<HTMLDivElement>(null);

  // Close on Escape
  useEffect(() => {
    const handleKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && isOpen) onClose();
    };
    document.addEventListener('keydown', handleKey);
    return () => document.removeEventListener('keydown', handleKey);
  }, [isOpen, onClose]);

  // Prevent body scroll when open
  useEffect(() => {
    document.body.style.overflow = isOpen ? 'hidden' : '';
    return () => { document.body.style.overflow = ''; };
  }, [isOpen]);

  // Focus first focusable element on open
  useEffect(() => {
    if (isOpen && drawerRef.current) {
      const el = drawerRef.current.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      el?.focus();
    }
  }, [isOpen]);

  return (
    <>
      {/* Overlay */}
      <div
        aria-hidden="true"
        data-open={isOpen}
        className="drawer-overlay"
        onClick={onClose}
      />

      {/* Drawer panel */}
      <div
        ref={drawerRef}
        role="dialog"
        aria-modal="true"
        aria-label={title}
        data-open={isOpen}
        className="drawer"
        style={{ width }}
      >
        <div className="flex items-center justify-between p-6 border-b border-white/10">
          {title && <h2 className="text-lg font-semibold text-white">{title}</h2>}
          <button
            onClick={onClose}
            aria-label="Close panel"
            className="ml-auto rounded-md p-1.5 text-white/60 hover:text-white hover:bg-white/10 transition-colors"
          >
            ✕
          </button>
        </div>
        <div className="p-6">{children}</div>
      </div>
    </>
  );
}

A few things worth calling out here. The data-open attribute drives the CSS transitions rather than toggling class names — this sidesteps the unmount timing problem where you remove an element from the DOM before the exit animation finishes. You can also use aria-hidden on the overlay so assistive tech ignores it while still capturing click events. The focus management on open is important for keyboard users and screen readers.

Tailwind v4 Variant for the Side Sheet

If you're on Tailwind v4.0.2 or newer, you get the data-* variant support baked in. That means you can ditch the separate CSS file entirely and write the transitions inline as utility classes.

// Tailwind v4 version — no external CSS needed
<div
  data-open={isOpen}
  className={[
    'fixed top-0 right-0 h-dvh max-w-[90vw] bg-[#0f0f0f]',
    'border-l border-white/[0.08]',
    'shadow-[-8px_0_32px_rgba(0,0,0,0.45)]',
    'translate-x-full data-[open=true]:translate-x-0',
    'transition-transform duration-[280ms] ease-[cubic-bezier(0.4,0,0.2,1)]',
    'will-change-transform z-50 overflow-y-auto',
  ].join(' ')}
  style={{ width: '420px' }}
/>

The data-[open=true]:translate-x-0 variant is what makes this work. Tailwind v4 generates a selector like [data-open="true"] .translate-x-0 under the hood, which matches the attribute we toggle from React state. Clean. No useState driving className juggling — the single boolean flows straight through to the DOM attribute and the CSS picks it up.

Focus Trapping: The Part Everyone Skips

Here's the thing: a drawer without focus trapping is an accessibility violation. When the panel is open, Tab should cycle only through elements inside the drawer, not behind it. It sounds annoying to implement, but it's actually maybe 25 lines.

The simplest approach is a useFocusTrap hook. On mount (or when isOpen flips true), collect all focusable elements inside the ref'd container, intercept Tab and Shift+Tab, and manually move focus to the first or last item in the list when you'd otherwise escape the boundary.

You could also reach for the inert HTML attribute on the page content behind the drawer. Set document.getElementById('main-content').inert = true when the drawer opens and remove it on close. Every modern browser in 2026 supports inert natively and it handles focus, pointer events, and aria-hidden all at once. It's the most ergonomic solution if you control the page structure. Just don't forget to remove it on cleanup — a useEffect return function is perfect for that.

Left, Right, Top, Bottom: Direction Variants

Most drawers slide from the right, but the pattern works from any edge. Bottom sheets are huge on mobile — think share menus, action sheets, cart summaries. Top drawers work well for notifications or global search panels. The implementation is almost identical: just change the transform axis.

For a bottom sheet, swap translateX(100%)translateY(100%), anchor to bottom: 0 instead of right: 0, set width: 100% and height to whatever you need (often max-h-[85dvh] with overflow-y: auto). The dvh unit matters here — it accounts for the browser's collapsible address bar on mobile, which vh doesn't.

If you're building a component library or design system, it's worth abstracting direction as a prop: 'left' | 'right' | 'top' | 'bottom'. Map each direction to its starting transform and positioning classes. This pairs nicely with animated tabs in React where the active tab might control which drawer direction opens — right for details, bottom for actions on mobile.

Stacking Multiple Drawers and Z-Index Management

What happens when you need a drawer inside a drawer? Filter panel opens from the right, user clicks a filter option that needs a nested detail drawer. It's a real pattern in complex dashboards. The naive z-index: 50 approach breaks immediately.

The cleanest solution is a drawer context that maintains a stack and assigns z-indices dynamically. Each drawer that mounts pushes itself onto the stack and gets z-index = 50 + stackPosition * 10. When it unmounts (after the exit animation), it pops off. The overlay opacity should also scale: the first drawer gets rgba(0,0,0,0.55), the second adds another rgba(0,0,0,0.35) layer on top.

For the vast majority of apps, you'll never need this. But if you're building something like a settings panel where clicking a setting opens a sub-panel, worth planning the architecture before you're neck-deep in z-index debugging. This is also where component libraries like Empire UI's card stack patterns offer inspiration — layered surfaces with intentional depth signals work the same way structurally.

Integrating with Empire UI Styles

Empire UI ships 40 visual styles — glassmorphism, neon, brutalist, retro, and more. Applying them to a side sheet is mostly a matter of swapping the background, border, and shadow values. A glassmorphism drawer uses background: rgba(255,255,255,0.08) and backdrop-filter: blur(20px) on the panel surface. A neon variant cranks the border up to 1px solid rgba(0, 255, 170, 0.4) and adds a matching box-shadow glow.

The width prop on the component we built above accepts any CSS value, so 420px, 40vw, min(480px, 90vw) — whatever the design needs. For responsive behavior, you'll typically want the drawer full-width on mobile (100vw) and a fixed pixel value on desktop. A Tailwind responsive variant handles this: set the width style to '100vw' and override via a CSS custom property keyed to a media query if you prefer to keep everything in Tailwind.

Once you've got the drawer wired up, think about what you're putting inside it. A filters panel might contain form inputs and an animated button to apply the selection. A settings drawer might use a theme toggle to switch between light and dark mode right inside the panel. The drawer is a container — the real work is in what you compose inside it.

FAQ

How do I prevent the body from scrolling when the drawer is open?

Set document.body.style.overflow = 'hidden' in a useEffect when isOpen is true, and reset it to an empty string in the cleanup function. Make sure you also clean up on component unmount — otherwise you'll lock scroll permanently if the drawer component is removed while open.

Should I use `visibility: hidden` or keep the drawer in the DOM when closed?

Keep it in the DOM and use transform: translateX(100%) to hide it visually. If you unmount it on close, you lose the exit animation because React removes the element immediately before the transition can play. The DOM-always-mounted approach is standard for animated overlays. Add aria-hidden='true' when closed if you want to remove it from the accessibility tree.

What's the right z-index for a drawer overlay?

There's no universal answer, but a common convention is z-index: 40 for the overlay and z-index: 50 for the drawer panel itself. The important thing is that both values sit above all other content on the page (navbars, sticky headers, tooltips). Document your z-index scale in a single place — a CSS custom properties file or a Tailwind config extension — so the values don't drift.

How do I animate the drawer closing, not just opening?

With the data-open attribute approach, the CSS transition runs in both directions automatically. When data-open flips from true to false, the browser transitions back to translateX(100%). The only pitfall is if you conditionally render the drawer with {isOpen && <Drawer />} — that unmounts the element instantly and skips the exit animation. Always render the drawer and use CSS transforms to show or hide it.

Can I use Framer Motion instead of pure CSS for the drawer animation?

Absolutely. Replace the CSS transition with <motion.div animate={{ x: isOpen ? 0 : '100%' }} transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }}>. Framer Motion handles the exit animation cleanly with AnimatePresence if you want to unmount the element after close. The tradeoff is a ~40 kB dependency. For most projects, the pure CSS approach is faster to load and easier to debug.

How wide should a side sheet drawer be?

On desktop, 400–480px is the sweet spot for detail panels. Filter panels can go narrower — around 320px. Anything wider than 600px starts to feel like a modal instead of a drawer. On mobile, go full-width (100vw) or close to it (95vw). A good rule: width: min(480px, 90vw) in CSS gives you a sensible default that scales from phone to widescreen without a media query.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Star Rating Component in React: Accessible, Animated, Themeable@Mention Input in React: Slack-style User Tagging ComponentReact Animation Best Practices: Performance, Accessibility, APIsTailwind Form Validation UI: Error States, Success, Loading