ui-005

Draggable Bottom Sheet

£10 or the whole Vault, £99

A multi-detent bottom sheet: peek, half and full, snapped by release velocity, with scrollable content that hands the gesture back to the sheet only at the top of the list.

Draggable intermediate
7 more details
micro-interactiondraggabletouch-dragsnapmomentumkeyboard-navigationdialog-animation

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

Updated

About this component

A bottom sheet at the level a real product needs it: three detents (peek, half and full) with springs between them, and a release that is decided by velocity rather than distance, so a short fast flick sends the sheet further than a long slow drag.

Read the full effect overview

The content inside it scrolls, and the hand-off is the part most sheets get wrong: a drag that starts in the list scrolls the list, and only a downward drag begun with the list already at its top takes the sheet with it. The backdrop's opacity and the sheet's own corner radius are driven by the live drag position, not by the open and close tweens, so the whole thing tracks the finger. Every gesture has a keyboard equivalent, the overlay contract is complete (Escape, focus moved in and returned, focus trapped, page behind locked), the bottom padding honours the safe-area inset, and there is a small public API with events. Two complete themes, light and dark, every colour and radius a custom property.

Draggable Bottom Sheet - UI component preview

What's included

11 items
  • Three detents, peek, half and full, with springs between them
  • Release velocity picks the detent: a short flick beats a long slow drag
  • Scrollable content with a real hand-off, the list scrolls until it is at the top
  • Backdrop opacity and the sheet's corner radius follow the live drag position
  • Flick it hard, or drag past the peek detent, and it dismisses
  • Arrow keys move between detents, Home and End jump to the ends, Escape dismisses
  • Full overlay contract: focus moved in and returned, focus trapped, page behind locked
  • Safe-area inset honoured, so it sits above a notched phone's home indicator
  • open(), snapTo(), close() plus sheet:open, sheet:snap and sheet:dismiss events
  • Start and reset states set by GSAP, not CSS, so the second open is never stale
  • Two complete themes, light and dark, every token defined in both

Perfect for

5 use cases
  • Delivery, ride and order tracking on mobile
  • Filter and sort panels on mobile listings
  • Map and search results that need a peek state over the map
  • Action sheets, settings and share panels
  • Cart, checkout and booking summaries

How it works

4 sections

Velocity decides, not distance

A tracker samples the drag position and time on every move to get a velocity in pixels per second. On release, the current position is projected 0.16 seconds ahead at that velocity, and the detent nearest the projected point wins, with the dismissed position counted as one of the stops. That single line is what makes a 50px flick send the sheet past half while a slow 300px drag settles back, and it is why the sheet feels thrown rather than dropped.

The scroll hand-off

One Draggable owns the whole sheet, so the decision of what a gesture means is made once, on its first move. A press inside the list, with the sheet full and the list already at scrollTop 0, only takes the sheet if the movement is downward; anything else routes the gesture to the list, where the script drives scrollTop itself and refuses to render Draggable's y. The release then either snaps the sheet to a detent or throws the list with its own momentum.

The reset that makes the second open right

The closed state is a percentage and the open state is a pixel offset, and the two are never tweened together. Closing travels to exactly one sheet height, then swaps to the percentage form at the moment the two are visually identical, and tells Draggable to re-read the position so its own idea matches the element. Skip that and the sheet opens wherever the last drag dropped it.

Detents measured, not hard-coded

The three offsets are derived from the sheet's own height and the height of its header, so peek always shows exactly the header and a sliver of the list. A ResizeObserver re-derives them whenever the box changes, which is how a theme switch, a font load or a rotation lands correctly without any JavaScript knowing a theme name.

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 Draggable Bottom Sheet 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

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

3. Copy the component markup from index.html — the block between <!-- Component starts here --> and <!-- Component ends here -->, plus a trigger button carrying data-sheet-open.

The script finds everything by data attribute, so you can rename classes and ids freely:

Attribute On Required
data-sheet the fixed overlay root; events are dispatched here yes
data-sheet-panel the sheet itself yes
data-sheet-head the non-scrolling header; its height sets the peek detent yes
data-sheet-handle the grab bar, role="slider", tabindex="0" recommended
data-sheet-scroll the scrolling content optional (omit for a short sheet)
data-sheet-backdrop the dimming layer, opacity driven by the drag optional
data-sheet-close any button that closes the sheet optional
data-sheet-open the trigger, outside the overlay optional (or call the API)
data-sheet-live a role="status" element for announcements optional

What's Included

  • index.html: the demo page and the markup to copy

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

  • assets/script.js: readable, commented source with an onReady guard

  • assets/script.min.js: minified production script

  • thumbnail.html: build-only capture state

  • Three detents — peek, half and full — derived from the sheet's measured height and the height of its header, re-derived by a ResizeObserver when the box changes, so a font load, a rotation or a restyle never leaves them stale

  • Release velocity picks the detent: the position is projected 0.16s ahead at the release velocity and the nearest stop wins, so a 50px flick outruns a 300px slow drag, and a hard flick down dismisses

  • Scrollable content with a real hand-off: a gesture that starts in the list scrolls the list (with its own momentum throw); only a downward drag begun with the list already at the top takes the sheet

  • Backdrop opacity and corner radius follow the live drag position, not the open and close tweens, so both track the finger and both survive a mid-flight reversal

  • The second open is never stale: the closed state is a yPercent, the open state is a pixel y, and closing resets one to the other before draggable.update()

  • Roles: dialog with aria-modal on the sheet, slider on the handle (aria-valuetext reads Peek, Half or Full), status for announcements, and aria-expanded on the trigger

Using It With Your Own Design

What the component needs from your markup: the data attributes above, a data-sheet-head that is a fixed-height block (the peek detent is measured from it), and a data-sheet-scroll that is the flex child which grows. Everything else — copy, rows, buttons — is yours.

What is only the demo: every .stage-* rule in assets/style.css (the handset screen the sheet opens over, and the .stage-narrow rule that shows the sheet at handset width so it lines up with that column), the toolbar strip and its inline CSS in index.html, and the two small inline scripts at the bottom of index.html — the one that fills the sheet from a tapped delivery row, and the one that mirrors the detent buttons. None of it ships with the component; delete all of it, along with the Google Font link if you already load a face.

CSS the component depends on:

  • No CSS transform on the sheet, ever. GSAP owns yPercent and Draggable owns y; a translateY(100%) in your stylesheet is read as a pixel offset that no yPercent tween undoes. That is also why the sheet is centred with margin: 0 auto between left: 0 and right: 0 rather than with translateX(-50%).
  • touch-action: none on the sheet: Draggable owns the vertical gesture, including inside the list, because the hand-off is decided in JavaScript.
  • data-lenis-prevent on data-sheet-scroll. It scrolls vertically only, which is the one case where that attribute is correct; never put it on a horizontal scroller or Lenis stops handling the vertical wheel as well.
  • Anything draggable inside the sheet needs draggable="false" and -webkit-user-drag: none (the stylesheet sets it for a and img). Otherwise the browser's native drag starts, the pointer stream is cancelled and the click never lands.
  • --radius-live is set on the sheet by the script each frame; keep border-top-*-radius: var(--radius-live, var(--radius-sheet)).
  • The footer's padding-bottom includes env(safe-area-inset-bottom), which is what keeps the primary button clear of a notched phone's home indicator. Your page needs viewport-fit=cover in its viewport meta for the inset to be non-zero.

Themes

Two themes, light (the default) and dark: the same design at two sets of token values. Pick one by setting the attribute on <body> in the demo, or on the component's owning root in your page (the ground colour is part of the look):

Code snippet omitted: it ships with the download.

Nothing else changes: same markup, same script. The demo's toggle and the ?variant=dark URL parameter only set that attribute, and the JavaScript never reads it — the radii it animates are read back out of the CSS at use time, and a ResizeObserver re-measures the detents when a switch changes the box.

Every colour, radius, shadow and font is a custom property defined in both body[data-variant="light"] and body[data-variant="dark"] in assets/style.css:

Token What it colours
--ground / --ground-2 the page behind, and a strip on it
--raised the sheet, its footer and the avatar
--hover the driver card, the close button, row hover
--ink / --ink-2 / --ink-3 primary text, values, labels and meta
--line / --line-strong hairlines and control borders
--accent / --accent-ink / --accent-wash the primary action, the live trail dot, focus rings, ::selection
--handle the grab bar
--radius / --radius-sm / --radius-xs cards, controls, tags
--radius-sheet / --radius-sheet-full the sheet's corner radius at rest and pulled all the way up; the script tweens between them as you drag
--shadow / --shadow-sheet elevation on the page, and the sheet's own
--backdrop / --backdrop-blur the dimming layer
--font the type (Inter in the demo)

To wear your own brand, re-value those properties in place rather than writing selector overrides — every one of them is read from the block, including both radii, so a rebrand never needs a new rule.

How It Works

Velocity decides, not distance. A tracker samples the pointer position and the clock on every move. On release the sheet's current y is projected 0.16 seconds ahead at that velocity, and whichever stop is nearest the projected point wins — with the dismissed position counted as a stop, so a hard flick throws the sheet off the bottom of the screen. This is the whole difference between a sheet that feels thrown and one that feels dropped.

The scroll hand-off. One Draggable owns the whole sheet, so what a gesture means is decided once, on its first move: a press outside the list, or with the sheet collapsed, moves the sheet; a press inside the list moves the list, unless the list is already at scrollTop: 0 and the drag is downward. In list mode the script writes scrollTop itself from the pointer delta and refuses to render Draggable's y, then throws the list with its own momentum on release. The decision is per gesture, as on iOS: scrolling to the top mid-swipe does not hand the sheet the rest of that swipe, it takes the next one.

The reset that makes the second open right. Closing tweens y to exactly one sheet height, then swaps to yPercent: 100, y: 0 at the moment those two are visually identical and calls draggable.update() so Draggable's own idea of the position matches the element. Without it the sheet reopens wherever the last drag dropped it — the single most common bug in a hand-rolled sheet.

Public API. The first instance is exposed on window:

Code snippet omitted: it ships with the download.

Events. The overlay root dispatches bubbling CustomEvents:

Code snippet omitted: it ships with the download.

Event detail
sheet:open { detent, source }
sheet:snap { detent, previous, source }
sheet:dismiss { from, source }

source is one of drag, keyboard, trigger, button, backdrop, handle or api, so you can tell a deliberate dismissal from a flick.

Keyboard & Accessibility

Key What it does
↑ / ↓ Moves up or down one detent (peek → half → full)
Home / End Jumps to the full or the peek detent
Escape Dismisses the sheet; focus returns to the trigger
Tab / Shift+Tab Cycles within the sheet while it is open
Enter / Space Activates rows, chips and buttons as normal
  • The detent keys are handled on the handle (a role="slider" with aria-valuetext of Peek, Half or Full) and on the header. Inside the list they are deliberately left alone, so arrowing through a radio group still works.
  • The sheet is a role="dialog" with aria-modal="true", labelled by its title. Opening moves focus to the handle, closing returns it to whatever opened the sheet, and Tab is trapped inside while it is open. Focus is never left inside the hidden panel, however the sheet was dismissed; a dismissal by drag or backdrop tap only declines to steal focus back from somewhere the user has since moved it.
  • The page behind is locked while the sheet is open, with the scrollbar's width compensated so the layout does not shift sideways.
  • Detent changes and dismissals are announced through a polite role="status" region.
  • Every row, chip and button is at least 40px tall, and coarse pointers get a larger handle and a wider grab area.
  • Under prefers-reduced-motion: reduce the sheet moves between the same states with zero duration, and the list's momentum throw is off. The component is never disabled.

Customisation

  • Detent positions: geom.peek / geom.half in measure() in assets/script.js — both are fractions of the measured height, so they stay right at any sheet size.
  • Feel: PROJECTION (how far a release is projected ahead) and FLICK_DISMISS (the velocity that dismisses outright) at the top of assets/script.js.
  • Corner behaviour: --radius-sheet and --radius-sheet-full per theme; the script tweens between them as you drag.
  • Colours live in assets/style.css as custom properties per theme (see Themes).
  • Easing and durations are the gsap.to calls in assets/script.js.

Requirements

  • GSAP 3.12+, 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.