ui-012

Slide-Out Drawer

£10 or the whole Vault, £99

A slide-out drawer for settings, carts and filters: flick to dismiss, rubber-band pull, a backdrop that tracks the drag, left or right.

Draggable intermediate
5 more details
dialog-animationdraggabletouch-dragstaggerkeyboard-navigation

This element reads better at your own screen size than in the frame below:

Updated

About this component

An invoice, cart, settings or filters drawer that slides in from the right or the left over a dimmed backdrop, with a fixed header, a scrollable body and a footer that stays put. The drag behaves like a native sheet: a quick flick toward the edge dismisses it from a few pixels, a slow short drag springs back, pulling the other way meets rubber-band resistance, and the backdrop fades with every pixel of travel.

Read the full effect overview

Mount any number with Drawer.mount(panel, { side }), open them from any data-drawer-open trigger, and listen for drawer:open and drawer:close. Escape, a focus trap, focus return and a scroll lock that holds on every page come built in, and destroy() reverts everything. Light and dark themes on one token list; GSAP plus Draggable.

Slide-Out Drawer - UI component preview

What's included

10 items
  • A fast flick toward the edge dismisses from a short drag; a slow short drag springs back
  • Rubber-band resistance when pulled past the open edge, easing home on release
  • The backdrop's opacity tracks the drag and recovers on a spring-back
  • Right or left side, any number of drawers per page
  • Drawer.mount(panel, options) with open, close and destroy, plus drawer:open and drawer:close events
  • Any data-drawer-open trigger opens its drawer; any data-drawer-close button closes it
  • Fixed header and footer with a body that scrolls independently, touch scrolling included
  • The page behind is locked while open and restored to the same offset
  • Escape, focus trapped while open and returned to the trigger
  • Light and dark themes on one token list

Perfect for

5 use cases
  • Settings and preferences panels in an app shell
  • Cart and checkout drawers on a store
  • Filter panels on a listing or search page, opened from the left
  • Detail inspectors in a dashboard, opened from a table row
  • Mobile navigation and sheets that should dismiss with a flick

How it works

4 sections

GSAP owns the start state

The panel carries no CSS transform: GSAP puts it off-screen itself, so opening is a plain percentage tween. A CSS translateX(100%) would be read as a pixel offset that a percentage tween can never undo, which is the bug that breaks most hand-rolled drawers on their second open.

A proxy drag with a rubber band

Draggable runs on an invisible proxy with the panel as its trigger, and the panel follows the pointer through a mapping: one to one toward the closed edge, and past the open edge through a curve that gives half the movement at first and never more than 48px. Driving the panel directly would let bounds clamp it, which is exactly the movement the rubber band needs.

Velocity first, then distance

Drag frames are sampled with timestamps and the release velocity is measured over the last 100ms, so a finger that stopped has none. A flick faster than the threshold closes from any distance, and the exit tween's duration is chosen so power2.out starts at the finger's speed. A slow drag past a third of the width closes; anything else springs back while the backdrop's opacity, which tracked every frame, recovers.

Drop-in and clean to remove

Drawer.mount returns open, close, toggle and destroy, triggers are matched by data-drawer-open, and drawer:open and drawer:close bubble with the reason. The scroll lock covers the document element as well as the body and is counted across drawers. Everything GSAP writes lives in one gsap.context, so destroy() reverts it along with every listener.

Plugins Draggable
Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Slide-Out Drawer opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page (a right-hand invoice drawer and a left-hand filters drawer) and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source exposing one global, Drawer

  • Slides in on power3 easing with a backdrop that fades to its own theme colour

  • Velocity dismiss: a fast flick toward the closed edge closes it from a drag of a few pixels, and the exit carries the flick's speed; a slow, short drag springs back open

  • A slow drag past a third of the panel's width also closes it

  • Rubber-band resistance when dragged past the open edge: the panel gives less and less, never more than 48px, and eases home on release

  • The backdrop tracks the drag: its opacity follows how far the panel has travelled, and recovers if it springs back

  • Right or left side from one option; any number of drawers per page

  • Any [data-drawer-open="<name>"] element opens its drawer; any [data-drawer-close] inside closes it

  • drawer:open and drawer:close events, with the reason the drawer closed

  • Fixed header and footer with a body that scrolls independently, on a styled scrollbar (--scroll-thumb); vertical touch scrolling in the body still works

  • Panel inset from the viewport on all four sides (--panel-inset) so every corner curve is visible

  • The page behind is locked while a drawer is open, and lands back at the same scroll position

  • Closes on backdrop click, Escape, and any close button

  • Focus moves into the panel, is trapped while open, and returns to whatever opened it

  • destroy() removes every listener and reverts every inline style GSAP wrote

Quick Start

Code snippet omitted: it ships with the download.

Drawer is defined as soon as assets/script.js runs; mount after the DOM is ready. Copy the full markup from index.html for the header chip, rows and footer. Fill .drawer-body with whatever you like; .drawer-group is only the stagger hook.

Upgrading from the id-based version. The previous release found its elements by id on load (openDrawerBtn, drawerBackdrop, drawerPanel, drawerClose, drawerSave). That markup still works unchanged: if the page has a #drawerPanel without data-drawer, the script mounts it on load with #openDrawerBtn as its trigger and #drawerClose / #drawerSave as close buttons. The window.openDrawer and window.closeDrawer globals are gone; use Drawer.get('drawerPanel').open() and .close() instead. The backdrop is now faded with opacity over background: var(--backdrop), so take the new assets/style.css with the new script.

Demo furniture. The 44px toolbar strip at the top of the demo page and the .stage-doc article behind the drawers are there so you can flip the theme and watch the scroll lock hold. Neither is part of the component: the strip's CSS lives in index.html and the .stage-* rules can be deleted from assets/style.css. Nothing in assets/script.js reads a .stage- class. index.html also nudges the panels and backdrops below the strip; the component's own CSS puts them against the viewport edge.

API

Drawer.mount(panel, options)

Mounts one drawer and returns its instance. Mounting the same panel twice returns the existing instance.

Option Default What it does
side 'right' 'right' or 'left'. Also read from data-side on the panel. Sets data-side so the CSS anchors and shades the correct edge
name data-drawer, then the panel id Which [data-drawer-open="<name>"] triggers open it
backdrop the .drawer-backdrop directly before the panel An element to use as the backdrop. If there is none, one is created (and removed again by destroy())
triggers null Extra triggers besides the data-drawer-open ones: a selector, an element or a list
closeSelector '[data-drawer-close]' Elements inside the panel that close it
staggerSelector '.drawer-group' Children that stagger in as the panel lands; null for none
initialFocus first [data-drawer-close] Selector inside the panel to focus on open
dismissDistance 0.33 Fraction of the panel's width a slow drag must travel to close
flingVelocity 400 Speed toward the closed edge, in px/s, that closes from any distance
rubberBand 48 The furthest the panel can be pulled past its open edge, in px

Instance

Method What it does
open(trigger?) Opens the drawer. Focus returns to trigger on close; without one it returns to whatever had focus
close() Closes the drawer and returns focus
toggle(trigger?) Opens or closes
isOpen() true while open
destroy() Closes without animating, unlocks the page, removes every listener, kills the Draggable and reverts every inline style it wrote
panel, name The panel element and its name

Drawer.get(nameOrPanel) returns a mounted instance, or null.

Events

Both bubble from the panel, so one listener on document hears every drawer.

Event detail
drawer:open { name, drawer, trigger }
drawer:close { name, drawer, reason }, where reason is 'button', 'backdrop', 'escape', 'drag' or 'api'

Code snippet omitted: it ships with the download.

Keyboard and ARIA

  • The panel is role="dialog" aria-modal="true", labelled by the <h2> in its header. Triggers carry aria-haspopup="dialog" and aria-controls; the script keeps aria-expanded in step on every trigger for that drawer (and adds aria-controls if the panel has an id and the trigger lacks it).
  • Escape closes the topmost open drawer.
  • Tab / Shift+Tab cycle inside the panel and never reach the page behind. Focus that lands on the scrollable body (browsers make a scroll container keyboard-focusable so it can be scrolled with the arrow keys) is left where it is, so every control in the footer is still reachable.
  • Opening moves focus to the first close button (or initialFocus); closing hands it back to the trigger. A drag-dismiss does not, so focus is not yanked to a button the pointer user never touched.
  • Only one drawer answers the keyboard at a time: the one opened last.
  • The drag is a pointer enhancement. Everything it does is also reachable from the keyboard.

How It Works

GSAP owns the start state. The panel carries no CSS transform. gsap.set(panel, { xPercent: 100 }) (or -100 on the left) puts it off-screen, so opening is a plain xPercent tween. A CSS translateX(100%) would be read by GSAP as a pixel x that an xPercent tween can never undo, which is the bug that breaks most hand-rolled drawers on their second open.

The drag runs on a proxy. Draggable.create targets an invisible element with the panel as its trigger, and the panel follows through a mapping read from the pointer, not from this.x. Toward the closed edge it tracks the finger one to one. Past the open edge the distance goes through max * (1 - 1 / (d * 0.55 / max + 1)): half speed at first, then less and less, never past rubberBand pixels. Driving the panel directly would let Draggable's bounds clamp it, which is exactly the movement the rubber band needs. Grabbing the panel mid-tween takes it from where it is.

Velocity decides, then distance. Every drag frame is sampled with its timestamp. On release the velocity is measured over the last 100ms, so a finger that stopped before letting go has none. Faster than flingVelocity toward the edge closes from any distance; past dismissDistance closes unless it was flung back toward open; anything else springs back on back.out. A flung close uses power2.out with a duration of 2 x remaining distance / velocity, which is the duration at which that ease starts at the finger's speed, so the panel leaves the hand without a hitch.

The backdrop follows. Its colour is var(--backdrop) in CSS and only its opacity is animated: 1 - distance / (panel width + inset) on every drag frame, back to 1 on a spring-back and to 0 on close. A theme change needs no JavaScript.

Touch. Draggable writes touch-action: manipulation onto the panel and every child, which lets the browser pan on both axes and cancel a sideways swipe on its first move. The stylesheet overrides it with touch-action: pan-y pinch-zoom !important on .drawer-panel and its children, so vertical scrolling in the body stays native and the horizontal belongs to the drag.

The scroll lock. <html> is locked as well as <body>: <body>'s overflow only reaches the viewport while <html>'s own overflow is visible, so on any page whose root sets an overflow of its own a body-only lock does nothing at all. Hiding the overflow also collapses the scroll range, so the body is pinned at its own negative scroll offset and the page behind stays exactly where the reader left it. The lock is counted across every drawer on the page, so a second open cannot double-lock and lose the offset.

Clean teardown. Every tween, set and the Draggable itself are created inside one gsap.context, and destroy() calls revert() on it, which hands back the markup's inline styles exactly as they were before mount.

Themes

The component ships in two themes, light (the default) and dark: one design at two sets of token values. Every colour and shadow it uses is a CSS custom property, and each theme is one block in assets/style.css, so the script is identical across both and never reads the theme name.

Pick one: set the attribute on <body> in the demo, or on your own wrapper around the component.

Code snippet omitted: it ships with the download.

Leaving the attribute off gives you light. The demo's toggle and the ?variant=dark URL parameter only set this attribute; neither is part of the component.

Re-value it for your brand: change the tokens in the body[data-variant="light"] and body[data-variant="dark"] blocks. The short list worth touching is --accent / --accent-ink (the trigger, the primary action and the focus ring), --ground / --ground-2 / --raised / --hover (the surface scale), --ink / --ink-2 / --ink-3 (text, values, labels), --line / --line-strong (hairlines), --warn-wash / --warn-ink (the status chip), --radius / --radius-sm / --radius-xs, --shadow / --panel-shadow / --panel-shadow-left, --backdrop and --scroll-thumb. Give text a solid colour rather than a translucent one, or its contrast cannot be measured. Nothing in assets/script.js needs to change: no tween touches a colour, and the backdrop only fades its opacity over --backdrop.

Customisation

  • Colours, radii and shadows live in assets/style.css as custom properties on each body[data-variant] block. The left drawer's shadow is --panel-shadow-left.
  • Gesture feel: flingVelocity, dismissDistance and rubberBand in the mount options.
  • Width: .drawer-panel { width: min(420px, calc(100vw - var(--panel-inset) * 2)) }. The drag reads the width live, so it follows.
  • --panel-inset is read on every open, close, drag and resize: it is the gap between the panel and the viewport edge, and what the panel travels past on the way out.
  • Reduced motion: every tween runs at zero duration, checked at the moment it runs. The drag still follows the pointer, because the user is moving it.
  • .drawer-trigger--secondary is the quieter trigger style for a second drawer on the same page.

Requirements

  • GSAP 3.12+ (demo uses 3.15.0), plus the Draggable plugin
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.