Skip to content

Bottom Sheet

OverlaysSince 0.3.0
import { BottomSheet } from '@absoluteui/components/bottom-sheet';
import '@absoluteui/components/bottom-sheet/style.css';

Using Angular? See Bottom Sheet for Angular.

A panel attached to the bottom edge of the viewport, built on native <dialog> like Dialog and Drawer.

  • Modal (default): showModal() gives top-layer rendering, an inert background, a backdrop, and native focus containment. AbsoluteUI adds scroll lock and focus restoration.
  • Non-modal (modal: false): show() keeps the page interactive. Focus moves into the sheet on open, and Escape closes it when it is the topmost overlay.

Optional snap points set the height as fractions of the viewport height. The drag handle resizes the sheet and, dragged down, dismisses it. Dragging is never the only way: with two or more snap points the handle is a keyboard-operable slider and a single click cycles the heights (WCAG 2.5.7), and Escape and the close button dismiss. Close reasons and cancelable close events mirror Drawer.

  • Short, contextual tasks on small screens, such as share targets, quick actions, or a filter panel.
  • Supplementary detail that users resize while keeping page context visible, such as a map result list (non-modal, with snap points).
  • Mobile-first flows where a panel reachable with the thumb is preferable to a centered dialog.
  • Short confirmations or destructive prompts: use Dialog (role=alertdialog).
  • Long forms or multi-step flows: use a page or a Drawer.
  • Content anchored to a trigger: use a popover or menu.
  • Menus of commands on desktop layouts: use Menu.
  • Non-modal sheets for tasks that must be completed before continuing: use a modal sheet.
import { BottomSheet, type BottomSheetCloseDetail } from '@absoluteui/components/bottom-sheet';
const targets = document.createElement('ul');
const sheet = BottomSheet.create({ title: 'Share', description: 'Choose where to send the link.', content: targets });
sheet.element.addEventListener('aui-close', (event) => {
const { reason } = (event as CustomEvent<BottomSheetCloseDetail>).detail;
if (reason === 'drag') {
// the user dragged the sheet away
}
});
document.querySelector('#share')?.addEventListener('click', () => {
sheet.open();
});
import { BottomSheet, type BottomSheetSnapChangeDetail } from '@absoluteui/components/bottom-sheet';
const results = BottomSheet.create({
title: 'Nearby places',
modal: false,
snapPoints: [0.25, 0.5, 0.9],
snap: 1,
closeOnDragDown: false,
});
results.element.addEventListener('aui-snap-change', (event) => {
const { value } = (event as CustomEvent<BottomSheetSnapChangeDetail>).detail;
results.body.classList.toggle('is-expanded', value >= 0.9);
});
results.open();
results.snapTo(2);
import { BottomSheet, type BottomSheetCloseDetail } from '@absoluteui/components/bottom-sheet';
let dirty = false;
const form = document.createElement('form');
form.addEventListener('input', () => {
dirty = true;
});
const sheet = BottomSheet.create({ title: 'Add note', content: form, closeOnBackdrop: false });
sheet.element.addEventListener('aui-cancel', (event) => {
const { reason } = (event as CustomEvent<BottomSheetCloseDetail>).detail;
// Accidental dismissals (Escape, drag) are blocked; the close button still closes.
if (dirty && reason !== 'close-button') event.preventDefault();
});
sheet.open();
import { BottomSheet, type BottomSheetSnapChangeDetail } from '@absoluteui/components/bottom-sheet';
import '@absoluteui/components/button/style.css';
import '@absoluteui/components/bottom-sheet/style.css';
const results = BottomSheet.create({ title: 'Nearby places', modal: false, snapPoints: [0.25, 0.5, 0.9], snap: 1 });
results.element.addEventListener('aui-snap-change', (event) => {
const { value } = (event as CustomEvent<BottomSheetSnapChangeDetail>).detail;
results.body.classList.toggle('is-expanded', value >= 0.9);
});
document.querySelector('#show-results')?.addEventListener('click', () => {
results.open();
});
import { BottomSheet } from '@absoluteui/components/bottom-sheet';
const sheet = BottomSheet.create({ title: 'Share', description: 'Choose where to send the link.' });
sheet.body.append(document.createElement('ul'));
sheet.open();
<dialog class="aui-bottom-sheet aui-bottom-sheet--{modal|non-modal} [aui-bottom-sheet--snapped]
[aui-bottom-sheet--dragging] [aui-bottom-sheet--static]"
aria-labelledby aria-describedby style="--aui-bottom-sheet-snap (CSSOM)">
<header class="aui-bottom-sheet__header">
<h2 class="aui-bottom-sheet__title">title</h2>
<button class="aui-button aui-bottom-sheet__close" aria-label="Close">icon</button>
</header>
<div class="aui-bottom-sheet__handle" (shown first through CSS order: -1)
role="slider" tabindex="0" aria-label="Sheet height" aria-orientation="vertical"
aria-valuemin aria-valuemax aria-valuenow aria-valuetext> (two or more snap points)
| aria-hidden="true"> (otherwise)
<span class="aui-bottom-sheet__grip"></span>
</div>
<p class="aui-bottom-sheet__description" hidden?>description</p>
<div class="aui-bottom-sheet__body">content</div>
</dialog>

The header precedes the handle in DOM order so the native initial focus lands on the close button, not the handle. The element is appended to document.body at creation.

BottomSheet.create(options: BottomSheetOptions, doc?: Document): BottomSheet

Global configuration key: bottomSheet (see Configuration).

Option Type Default Description
title string Accessible name, rendered as the heading (text only). Must be non-empty after trimming. Required.
description string Referenced by aria-describedby (text only). Hidden when empty.
content Node | string Body content; strings render as text. Inserted at construction and again only when update() passes content.
snapPoints readonly number[] Heights as fractions of the viewport height, strictly ascending, each in (0, 1]. Without snap points the sheet fits its content (capped at 90dvh). Two or more make the handle a slider.
snap number 0 Index into snapPoints used when the sheet opens (0 = lowest). Must be 0 without snap points. When set, open() re-applies it.
modal boolean true true: showModal() with inert background, backdrop and scroll lock. false: show(); the page stays interactive (supplementary panels only). Set at creation only.
icons Partial<{ close: IconRef }> Icon slot overrides.
closeOnEscape boolean true Escape requests close (cancelable, reason ‘escape’) when the sheet is the topmost overlay. Globally configurable.
closeOnBackdrop boolean true Backdrop click requests close (cancelable, reason ‘backdrop’). Modal only. Globally configurable.
showCloseButton boolean true Render the header close button (reason ‘close-button’). Globally configurable.
closeOnDragDown boolean true Dragging the handle down past min(120px, 25% of the lowest height) requests close (cancelable, reason ‘drag’). Globally configurable.
Property Type Description
element HTMLDialogElement The native dialog element (appended to document.body at creation).
body HTMLDivElement Consumer content area.
handle HTMLDivElement The drag handle strip: role=slider with two or more snap points, otherwise aria-hidden.
isOpen boolean Whether the sheet is open.
modal boolean Whether the sheet was created modal.
snap number Current snap point index (0 without snap points).
Method Description
open(): void Open (modal: showModal() and scroll lock; non-modal: show()); moves focus inside, captures focus for restoration, registers with the overlay stack, emits aui-open. No-op when open.
close(): void Close programmatically (not cancelable, reason ‘programmatic’). No-op when closed.
snapTo(index: number): void Move to a snap point programmatically; no event. Throws BOTTOM_SHEET_OPTIONS for an index outside snapPoints.
update(options: BottomSheetUpdateOptions): void Merge and apply options (all except modal). Validates the merged options first; a failed update keeps the previous state.
destroy(): void Close if open (restoring focus and scroll), then remove the element, listeners, drag tracking, overlay registration, and subscriptions.
Event Detail Bubbles Cancelable Description
aui-open null No No After the sheet opened.
aui-cancel { reason: 'escape' | 'backdrop' | 'close-button' | 'drag' } No Yes Before a user-initiated close; preventDefault keeps it open (a dismissing drag snaps back).
aui-close { reason: BottomSheetCloseReason } No No After the sheet closed.
aui-snap-change { index: number; value: number } No No The user changed the snap point (keyboard, handle click, or drag release). value is the viewport fraction. Not emitted by snapTo() or update().
Area Description
body BottomSheet.body (or the content option).

All rules live in the aui.components cascade layer, so application styles override them without specificity battles.

Class Purpose
aui-bottom-sheet Native <dialog> element.
aui-bottom-sheet--modal | --non-modal Modality modifiers.
aui-bottom-sheet--snapped A snap point is active; block-size is calc(100dvh * –aui-bottom-sheet-snap).
aui-bottom-sheet--dragging A handle drag is in progress; transitions are off.
aui-bottom-sheet--static Set on open when motion is disabled (reduced motion or animation ‘none’); removes transitions.
aui-bottom-sheet__handle Full-width drag strip (shown first through CSS order).
aui-bottom-sheet__grip Visual grip bar inside the handle.
aui-bottom-sheet__header Title row with close button.
aui-bottom-sheet__title Heading (h2).
aui-bottom-sheet__close Close button (Button, ghost, sm).
aui-bottom-sheet__description Description paragraph.
aui-bottom-sheet__body Scrollable content area.
--aui-bottom-sheet-snap Custom property set through the CSSOM: active snap fraction.
--aui-bottom-sheet-offset Custom property set through the CSSOM during a drag: downward slide in px.
--aui-bottom-sheet-live-height Custom property set through the CSSOM during a drag with snap points: live height in px.
Slot Default icon
close close

Tokens this stylesheet reads (verified in CI). Override them in a theme or with the Theme Designer.

--aui-border-width | --aui-color-backdrop | --aui-color-border | --aui-color-border-strong | --aui-color-focus-ring | --aui-color-surface | --aui-color-text | --aui-color-text-muted | --aui-control-height-sm | --aui-drawer-width-lg | --aui-duration-fast | --aui-duration-normal | --aui-easing-standard | --aui-focus-ring-offset | --aui-focus-ring-width | --aui-font-family-sans | --aui-font-size-lg | --aui-font-size-md | --aui-font-size-sm | --aui-font-weight-semibold | --aui-line-height-normal | --aui-line-height-tight | --aui-radius-lg | --aui-radius-pill | --aui-shadow-lg | --aui-space-1 | --aui-space-2 | --aui-space-3 | --aui-space-5

type BottomSheetCloseReason = 'escape' | 'backdrop' | 'close-button' | 'drag' | 'programmatic';
interface BottomSheetConfig { closeOnEscape: boolean; closeOnBackdrop: boolean; showCloseButton: boolean; closeOnDragDown: boolean }
interface BottomSheetIcons { close: IconRef }
interface BottomSheetOptions extends Partial<BottomSheetConfig> {
title: string;
description?: string;
content?: Node | string;
snapPoints?: readonly number[];
snap?: number;
modal?: boolean;
icons?: Partial<BottomSheetIcons>;
}
type BottomSheetUpdateOptions = Partial<Omit<BottomSheetOptions, 'modal'>>;
interface BottomSheetCloseDetail { reason: BottomSheetCloseReason }
interface BottomSheetSnapChangeDetail { index: number; value: number }

Pattern: WAI-ARIA APG Dialog (Modal) pattern implemented with native <dialog>.showModal(); non-modal sheets use <dialog>.show(). With two or more snap points the drag handle follows the APG Slider pattern.

Key Context Action
Tab / Shift+Tab Modal sheet open Moves focus among sheet controls (close button, handle slider, content); the background is inert.
Tab / Shift+Tab Non-modal sheet open Follows document order; focus can leave the sheet and reach the page.
Escape Sheet is the topmost overlay, closeOnEscape=true Emits cancelable aui-cancel (reason ‘escape’), then closes and restores focus.
Escape closeOnEscape=false No effect; the native close request is prevented, including repeated presses.
Escape Handle drag in progress Cancels the drag only; the sheet returns to its height and stays open.
Escape Nested overlay open Closes only the topmost overlay.
ArrowUp / ArrowRight / PageUp Handle slider focused Next larger snap point (stops at the largest); emits aui-snap-change.
ArrowDown / ArrowLeft / PageDown Handle slider focused Next smaller snap point (stops at the smallest); emits aui-snap-change. Never closes the sheet.
Home / End Handle slider focused Smallest / largest snap point.
Enter / Space Close button or content button focused Activates the button.
Element Role
<dialog> dialog (native)
title heading level 2
handle (two or more snap points) slider
handle (fewer than two snap points) none (aria-hidden, decorative)
Attribute Element Value
aria-labelledby <dialog> id of the title heading
aria-describedby <dialog> id of the description (when provided)
aria-modal <dialog> Implicit true for modal sheets (native showModal()); absent for non-modal sheets
aria-label close button Localized ‘Close’
aria-label handle slider Localized ‘Sheet height’
aria-orientation handle slider vertical
aria-valuemin / aria-valuemax handle slider Lowest / highest snap point as a whole percent of the viewport height
aria-valuenow handle slider Current snap point as a whole percent
aria-valuetext handle slider Localized ‘{percent}% of screen height’
tabindex handle slider 0
aria-hidden handle (fewer than two snap points) true (no role, not focusable)

On open, focus moves to the first focusable element (native dialog focusing steps): the close button when shown, since it precedes the handle in DOM order. A non-modal sheet with no focusable content focuses the dialog itself (tabindex -1). On close, a modal sheet returns focus to the element focused before opening; a non-modal sheet does so only when focus was inside the sheet or on the body. Hiding the focused close button through configuration moves focus to the dialog.

Criterion Level Note
1.3.1 Info and Relationships A
2.1.1 Keyboard A
2.1.2 No Keyboard Trap A
2.4.3 Focus Order A
2.4.7 Focus Visible AA
2.5.7 Dragging Movements AA
2.5.8 Target Size (Minimum) AA
4.1.2 Name, Role, Value A

Automated test results: Accessibility and compliance.

  • Dragging is never the only way to operate the sheet (WCAG 2.5.7): with two or more snap points a single click on the handle cycles the heights and the handle is keyboard-operable; dismissal is also available through Escape and the close button.
  • The handle strip spans the full width at control-height-sm height, a large touch target (WCAG 2.5.8).
  • Background inertness and top-layer rendering of modal sheets are provided by the browser.
  • Slide motion is removed under prefers-reduced-motion and data-aui-animation=‘none’.
  • The handle is shown first visually but follows the header in DOM and focus order.
  • Manual screen-reader verification (NVDA, JAWS, VoiceOver) is pending.
  • Snap changes are conveyed through the slider’s aria-valuetext only; there is no separate live-region announcement.
  • When closeOnEscape, showCloseButton, and closeOnDragDown are all false, the sheet has no built-in dismissal; the consumer must provide one.

closed, opening (slide-in), open, open at snap point N, dragging, closing (an aui-cancel listener is pending), and closing (slide-out).

From Trigger To
closed open() open at snap (focus moves inside; modal: scroll locked, background inert)
open Escape / backdrop (modal) / close button aui-cancel -> closed, unless prevented
open handle pointer moves more than 4 px dragging (transitions off)
dragging release, slide below the lowest height > min(120px, 25% of that height), closeOnDragDown: true aui-cancel (reason drag) -> closed; if prevented, back to open
dragging other release, two or more snap points open at the nearest snap point by height (aui-snap-change when it changed)
dragging other release, fewer than two snap points open (height unchanged)
dragging Escape, pointercancel, lost pointer capture open (drag canceled, height unchanged)
open at N arrow / Page / Home / End on the handle, or a handle click open at M (aui-snap-change)
open close() / destroy() closed (no aui-cancel)
open consumer <form method="dialog"> submit closed (reason programmatic)
  • Initial focus follows the native dialog focusing steps: the first focusable element, which is the close button when shown. A non-modal sheet with nothing focusable focuses the dialog (tabindex="-1").
  • A modal sheet returns focus to the element focused before open(). A non-modal sheet returns focus only when focus was inside the sheet (or on the body) at close.
  • Hiding the focused close button through configuration moves focus to the dialog.
  • Single-pointer alternative to dragging (WCAG 2.5.7): a click on the handle cycles the snap points; the click after a drag is suppressed.
  • The handle strip is full-width and control-height-sm tall (WCAG 2.5.8).
  • The handle is shown first visually but follows the header in focus order.
  • Slide motion is removed under prefers-reduced-motion and data-aui-animation='none'.
  • Manual screen-reader verification is pending.

Inline size min(100vw, drawer-width-lg), centered with margin: 0 auto; top corners use radius-lg. Without snap points the sheet fits its content up to max-block-size: 90dvh; with snap points block-size: calc(100dvh * snap) (up to 100dvh). The body scrolls with overscroll-behavior: contain. Horizontal padding is max(space-5, env(safe-area-inset-left|right)) and the body’s bottom padding is max(space-5, env(safe-area-inset-bottom)), so content clears notches and the home indicator. The handle uses touch-action: none.

No direction option. The sheet is centered and slides vertically, so the writing direction does not change placement. Header layout uses logical properties; the close button sits at the inline end. Slider arrow keys are not mirrored: ArrowRight and ArrowUp always enlarge the sheet.

Token-driven surface, border (no bottom border), top radius, shadow, backdrop, and grip color. Forced-colors mode uses CanvasText for the border and the grip.

configureAbsoluteUI({ components: { bottomSheet: { closeOnBackdrop: false, closeOnDragDown: false } } });

Globally configurable: closeOnEscape, closeOnBackdrop, showCloseButton, closeOnDragDown. Changes apply to existing instances immediately.

Instance options override global and theme defaults. All options are dynamic through update(); modal is init-only.

icons.close (default close), resolved through the global icon provider/registry. The grip is a CSS bar, not an icon.

Title, description, and string content use textContent. No HTML parsing. Drag and snap geometry is written as CSS custom properties through the CSSOM, never as markup style attributes, so the component is CSP-safe (style-src without 'unsafe-inline').

No persistence, network access, or telemetry. The snap index lives in memory.

Constant work per open/close: one showModal()/show() call, one overlay-stack entry, one scroll-lock reference (modal), and one focus restore point. During a drag, each pointermove reads the viewport height and writes at most two custom properties; the sheet height is measured once at drag start. Budget: open() under 16 ms (one frame) in headless Chromium.

Not data-driven. Body content size is limited only by the consumer; the body scrolls. Snap point count is unbounded, but more than three or four heights are hard to distinguish by dragging.

  • closeOnEscape: false also blocks repeated Escape presses (the native close request is always prevented).
  • A non-modal sheet yields Escape to a modal dialog opened elsewhere, because it is inert behind that dialog.
  • Escape during a handle drag is consumed by the drag and never reaches the overlay stack.
  • With exactly one snap point the height is fixed at that fraction; the handle is decorative and dragging only dismisses.
  • Without snap points the dismiss threshold is min(120px, 25% of the sheet height at drag start); with snap points it uses the lowest snap height.
  • A prevented aui-cancel after a dismissing drag returns the sheet to its current snap point.
  • open() re-applies the snap option when it is set; without it, the last snap index is kept across reopening.
  • update({ snapPoints }) without snap clamps both the current index and a kept snap option to the new list; an explicit out-of-range snap throws.
  • Consumer changes to body survive re-renders; only update({ content }) replaces the body.
  • Destroying an open sheet releases the scroll lock and overlay registration, and restores focus.
Code Category Cause
BOTTOM_SHEET_TITLE configuration Missing or blank title.
BOTTOM_SHEET_OPTIONS configuration Invalid modal, content, snapPoints (not strictly ascending numbers in (0, 1]), snap, or snapTo() index.
CFG_INVALID_VALUE / CFG_UNKNOWN_OPTION configuration Invalid or unknown configuration value, such as closeOnEscape: 'yes'.

A failed construction leaves nothing behind: no element in the document, listeners, overlay registration, or subscriptions. A failed update() keeps the previous options.

  • Non-modal sheets are not in the top layer. They use z-index: 1000, so page content with a higher stacking context can cover them.
  • Only the handle strip starts a drag; the header and body never drag the sheet.
  • Snap points are viewport fractions only.
  • Slide animations need @starting-style and transition-behavior: allow-discrete. Older browsers open and close without animation.
  • A <form method="dialog"> submit in the content closes with reason programmatic (there is no returnValue in the detail).
  • Manual screen-reader verification is pending.
  • Switching modal after creation.
  • Sheets on the top or side edges: use Drawer.
  • Pixel or content-measured snap heights.
  • HTML strings as content: pass a Node instead.