rc-bottom-sheet
Modal bottom-sheet wrapper for a native <dialog>, built on rc-dialog.
- Package
@rcarls/rc-bottom-sheet- Element
<rc-bottom-sheet>- Native dependency
- Requires a direct child native dialog
- State model
- Open state follows the native dialog
- Main events
rc-dialog-openrc-dialog-togglerc-dialog-request-closerc-dialog-cancelrc-dialog-closerc-bottom-sheet-resize-startrc-bottom-sheet-snap
Installation
- npm
- Yarn
- pnpm
npm install @rcarls/rc-webcomponents@next
yarn add @rcarls/rc-webcomponents@next
pnpm add @rcarls/rc-webcomponents@next
import '@rcarls/rc-webcomponents/rc-bottom-sheet/define';
Usage
Place a <dialog> element directly inside <rc-bottom-sheet>. The inner
dialog must have aria-labelledby or aria-label.
<rc-bottom-sheet id="filters" snap-points="40dvh 70dvh 100dvh">
<dialog aria-label="Filter projects">
<button
type="button"
data-rc-bottom-sheet-handle
data-rc-dialog-resize-axis="y"
data-rc-dialog-resize-origin="top"
aria-label="Resize sheet"
></button>
<button value="done" formmethod="dialog">Done</button>
</dialog>
</rc-bottom-sheet>
The element inherits rc-dialog methods and events, including showModal(),
close(), requestClose(), rc-dialog-toggle, and
rc-dialog-request-close. Bottom sheets default to closed-by="any" light dismiss, vertical
top-origin resize, downward swipe-dismiss, and fixed positioning at the
viewport's bottom edge. Add an optional
[data-rc-bottom-sheet-handle] element inside the dialog for a themed resize
handle. Use snap-points to snap the sheet to declared CSS heights after
resize gestures. Set resize="none" to hold the sheet at its current snap
point; the option is reactive, so restoring resize="vertical" re-enables
dragging.
The sheet stays docked at its block-end edge: snapping sets only its height,
and its position is left to CSS. A software keyboard that resizes the viewport
(interactive-widget=resizes-content) re-applies the current snap point above
the keyboard. When a field inside the sheet takes focus, the sheet scrolls it
into view within its own scroll containers, and grows past its snap height and
maximum block size only when scrolling cannot reveal it, returning to its snap
point once focus leaves the sheet.
Without a theme, the authored handle uses a 32 by 4 pixel pill indicator, centered inside a full-width, 48 pixel-high target.
The Substrate theme replaces that pill with the same three-dot visual language as its splitter handle while preserving the full-width pointer target. Material keeps the Material 3 pill indicator.
For an embedded non-modal sheet, place the component in a positioned
container, set --rc-bottom-sheet-position: absolute, and open it with
show(). The sheet then docks to that container's block-end edge rather than
the viewport.
Declare snap points as CSS heights in ascending order. Any length works,
including calc() and var(). Each point is resolved inside the sheet when a
snap applies, so a custom property the page sets or animates, for example from
a scroll timeline, is read at its current value. Percentages are of the viewport
height. Slow releases settle at the nearest point. A decisive upward or downward swipe (500 pixels per second
by default) settles at the last or first point, respectively. When
swipe-dismiss is enabled, dragging downward by at least 96 pixels requests
close before velocity-based snapping applies.
rc-bottom-sheet-resize-start fires before an interactive resize changes the
sheet geometry. Consumers can use it to prepare content that an expansion drag
will progressively reveal.
const sheet = document.querySelector('#filters');
sheet.showModal();
sheet.snapTo(1);
sheet.addEventListener('rc-bottom-sheet-snap', (event) => {
console.log(event.detail);
});
snapTo() clamps finite indices to the available range and ignores calls when
there are no valid snap points. Pass 'instant' as its second argument to
skip animation. The component also skips animation when the user has enabled
reduced motion. Snap events report the effective rendered height after CSS
minimum and maximum size constraints, and constrained targets remain docked to
the sheet's block-end edge. Animated snaps emit only after the settle motion
finishes; an interrupted animation does not emit a stale target. This makes the
event a safe point for discrete content or accessibility-state changes.
Live demo
When to use
Bottom sheets are a mobile-friendly alternative to rc-dialog for
non-blocking, dismissible prompts, such as a newsletter/mailing-list
signup or a general on-visit call-to-action. Docking to the bottom edge and
supporting swipe-dismiss keeps the prompt reachable by thumb and easy to
brush aside, where a centered rc-dialog modal would feel more disruptive
on a small screen.
Accessibility
The direct child <dialog> owns dialog semantics, focus, labeling, and form
behavior. Give it an accessible name with aria-labelledby or aria-label.
Use showModal() when interaction outside the sheet must be unavailable and
show() for a non-modal surface.
The optional drag handle must be a focusable control with an accessible name. Keep it as the dialog's first direct child and avoid adding dialog top padding so its 48 pixel target reaches the sheet's top edge.
rc-dialog supplies keyboard resizing for the authored handle, so pointer
dragging is not the only way to resize the sheet.
Theming
The component docks the dialog to the bottom edge and centers authored
[data-rc-bottom-sheet-handle] handles by default. rc-theme-material
applies the Material surface, level-one elevation, scrim, 32 by 4 pixel drag
indicator within a 48 pixel target, and text-button treatment to direct
authored action buttons. Wrap actions nested in sheet content with rc-button,
as in the live demo, so they
receive the shared Material button treatment without affecting component-owned
buttons such as those inside rc-chip.
Set --rc-bottom-sheet-snap-duration to a millisecond duration to customize
settling. The default is 300ms. Set --rc-bottom-sheet-snap-easing to a CSS
easing function to customize the settle curve. The theme-free fallback is
cubic-bezier(0.4, 0, 0.2, 1); themes can map both properties to their spatial
motion scheme.
API
Properties
| Property | Markup | Type | Default | Description |
|---|---|---|---|---|
closedBy | closed-by | 'any' | 'closerequest' | 'none' | '' | 'any' | Bottom sheets close on Escape or a backdrop click by default. Set `closed-by="closerequest"` when a backdrop click must not close the sheet, or `closed-by="none"` when only explicit actions may close it. |
resize | resize | 'none' | 'both' | 'horizontal' | 'vertical' | 'vertical' | Bottom sheets resize vertically from the top edge by default. |
resizeOrigin | resize-origin | ResizeOrigin | 'top' | Top-origin resizing keeps the sheet docked to the viewport bottom. |
resizeHandle | resize-handle | string | '[data-rc-bottom-sheet-handle]' | Default optional handle selector. Falls back to top-edge resize when absent. |
snapPoints | snap-points | string | '' | Whitespace-separated CSS heights in ascending order. Each height becomes an addressable snap target. Slow releases choose the nearest target, while decisive swipes choose the first or last target. Any CSS length works, including `calc()` and `var()`, and is resolved in the sheet's own context when a snap is applied, so a custom property the page animates (for example from a scroll timeline) is read at its current value. Percentages are of the viewport height. |
swipeDismiss | swipe-dismiss | boolean | true | Whether a downward pointer resize of at least 96 pixels requests close. When false, a decisive downward swipe settles at the first snap point. |
swipeVelocity | swipe-velocity | number | 500 | Minimum release velocity, in pixels per second, that counts as a decisive swipe rather than a slow deliberate drag. A swipe jumps straight to the extreme `snap-points` entry in its direction; below this, the release settles to the nearest point instead. |
movable | movable | boolean | false | Allow the dialog to be moved by dragging. Use `move-handle` to restrict dragging to a specific child element (e.g. a titlebar). Reactive: changing this or any other `move-*` option after setup rebuilds the move behavior and re-queries `move-handle`. |
moveHandle | move-handle | string | '' | CSS selector (within the inner `<dialog>`) for the drag handle element. |
moveBounds | move-bounds | 'viewport' | 'parent' | 'viewport' | Area that moving and resizing stay inside: `viewport`, or `parent` for the element that contains `<rc-dialog>`. |
moveStep | move-step | number | 4 | Keyboard arrow-key step in px for moving. |
resizeThreshold | resize-threshold | number | 8 | Edge hit-test thickness in px for resize detection. |
resizeStep | resize-step | number | 4 | Keyboard arrow-key step in px for resizing. |
variant | variant | RCDialogVariant | 'standard' | Surface presentation. Fullscreen geometry follows the visual viewport while open; a side sheet docks to the inline end at full block size. |
modal | JS property only | boolean | true | Whether controlled open state uses `showModal()` instead of `show()`. JavaScript property only; no attribute is observed. |
open | open | boolean | Not specified | Whether the inner `<dialog>` is currently open. Host writes update silently. |
defaultOpen | default-open | boolean | Not specified | Initial uncontrolled open state. |
returnValue | JS property only | string | Not specified | The return value set when the dialog was closed. |
Methods
| Method | Description |
|---|---|
snapTo(index: number, behavior: 'animated' | 'instant') | Snaps to a declared target by zero-based index. Finite fractional indices are truncated and out-of-range indices clamp to the nearest endpoint. Non-finite indices and calls without valid snap points have no effect. |
showModal() | Opens the inner `<dialog>` as a modal and fires `rc-dialog-open`. |
show() | Opens the inner `<dialog>` (non-modal) and fires `rc-dialog-open`. |
close(returnValue?: string) | Closes the inner `<dialog>`, optionally setting a return value. |
requestClose(returnValue?: string) | Requests the inner `<dialog>` to close, firing a cancelable `cancel` event first. |
Events
| Event | Detail type | Description |
|---|---|---|
rc-bottom-sheet-resize-start | CustomEvent | Fires when an interactive resize begins. `detail: { height, inputType }` |
rc-bottom-sheet-snap | CustomEvent | Fires after a drag release or `snapTo()` call settles at its snap target. `detail: { index, height, trigger }`, with `height` in the sheet's own CSS pixels, the unit of its snap points. |
rc-dialog-open | CustomEvent | Inherited from `rc-dialog`; fired when the sheet opens. |
rc-dialog-toggle | CustomEvent | Inherited from `rc-dialog`; fired when user/native interaction changes open state. |
rc-dialog-request-close | No detail type documented | Inherited from `rc-dialog`; cancelable close request. |
rc-dialog-cancel | No detail type documented | Inherited from `rc-dialog`; backward-compatible cancel alias. |
rc-dialog-close | No detail type documented | Inherited from `rc-dialog`; fired after the sheet closes. |
Slots
| Name | Description |
|---|---|
(default) | Place a `<dialog>` element with the sheet content and optional `[data-rc-bottom-sheet-handle]` resize handle here. |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--rc-bottom-sheet-bg | Canvas | Sheet surface background. |
--rc-bottom-sheet-color | CanvasText | Sheet text color. |
--rc-bottom-sheet-radius | 1rem 1rem 0 0 | Sheet corner radius. |
--rc-bottom-sheet-shadow | none | Sheet elevation shadow. |
--rc-bottom-sheet-position | fixed | Positioning scheme. Use `absolute` with `show()` to dock a non-modal sheet to a positioned ancestor. |
--rc-bottom-sheet-inset-block-end | 0px | Distance from the positioning container's block-end edge. |
--rc-bottom-sheet-inset-inline | 0px | Inline-axis inset used with automatic margins. |
--rc-bottom-sheet-max-inline-size | 40rem | Maximum sheet width. |
--rc-bottom-sheet-max-block-size | 70dvh | Maximum sheet height. |
--rc-bottom-sheet-padding | 0 1rem 1rem | Sheet padding. |
--rc-bottom-sheet-scrim | var(--rc-dialog-scrim) | Modal backdrop color. |
--rc-bottom-sheet-handle-color | GrayText | Resize handle color. |
--rc-bottom-sheet-handle-inline-size | 2rem | Resize handle width. |
--rc-bottom-sheet-handle-block-size | 0.25rem | Resize handle height. |
--rc-bottom-sheet-handle-radius | 999px | Resize handle corner radius. |
--rc-bottom-sheet-handle-offset | 50% | Block-start position of the handle indicator's center within its target. Set a length to pin it above a title inside the handle. |
--rc-bottom-sheet-handle-target-inline-size | 100% | Pointer target width for a direct-child handle. |
--rc-bottom-sheet-handle-target-block-size | 3rem | Pointer target height for a direct-child handle. |
--rc-bottom-sheet-handle-target-margin | 0 | Margin around a direct-child handle target. |
--rc-bottom-sheet-snap-duration | 300ms | Duration of the settle animation after a drag release or `snapTo()` call. |
--rc-bottom-sheet-snap-easing | cubic-bezier(0.4, 0, 0.2, 1) | Easing for the settle animation after a drag release or `snapTo()` call. |
--rc-dialog-inline-size | fit-content | Standard surface preferred inline size. Set `100%` for a task dialog whose fluid content would otherwise shrink it to its minimum. |
--rc-dialog-min-inline-size | auto | Standard surface minimum inline size. |
--rc-dialog-max-inline-size | none | Standard surface maximum inline size. |
--rc-dialog-max-block-size | calc(100dvb - 2rem) | Standard surface block bound. The surface never scrolls itself, so a scrollable dialog should scroll a dedicated inner region. |
--rc-dialog-padding | 1em | Standard surface padding. |
--rc-dialog-border | 0 | Surface border. |
--rc-dialog-radius | 0 | Surface corner radius. |
--rc-dialog-background | Canvas | Surface background. |
--rc-dialog-color | CanvasText | Surface foreground. |
--rc-dialog-shadow | none | Surface shadow. |
--rc-dialog-scrim | color-mix(in srgb, CanvasText 32%, transparent) | Modal backdrop color. |
--rc-dialog-fullscreen-padding-block | Not specified | Fullscreen block padding. Defaults to a safe-area-aware inset, which is the one value the fullscreen variant genuinely needs of its own. |
--rc-dialog-fullscreen-padding-inline | Not specified | Fullscreen inline padding, safe-area aware. |
--rc-dialog-fullscreen-background | Not specified | Fullscreen background. Defers to `--rc-dialog-background` when unset. |
--rc-dialog-side-inline-size | min(100%, 25rem) | Side sheet inline size. |
--rc-dialog-side-background | Not specified | Side sheet background. Defers to `--rc-dialog-background` when unset. |
--rc-dialog-side-radius | Not specified | Side sheet start-corner radius. Defers to `--rc-dialog-radius` when unset. |
--rc-dialog-side-shadow | Not specified | Side sheet shadow. Defers to `--rc-dialog-shadow` when unset. |
--rc-dialog-visual-viewport-left | Not specified | Read-only fullscreen visual viewport left edge. |
--rc-dialog-visual-viewport-top | Not specified | Read-only fullscreen visual viewport top edge. |
--rc-dialog-visual-viewport-width | Not specified | Read-only fullscreen visual viewport width. |
--rc-dialog-visual-viewport-height | Not specified | Read-only fullscreen visual viewport height. Fullscreen geometry, meaning position, size, margin, radius, and shadow, is structural rather than themable: filling the visual viewport is what keeps the surface correct under browser zoom and a software keyboard. While a keyboard closes, the surface takes only growth, so the shorter transitional sizes some mobile browsers report do not flash through. Side sheet placement is structural too: the surface docks to the inline end at full block size, with physical safe-area padding on every edge; only its inline size, background, start-corner radius, and shadow are themable. Neither docked variant moves or resizes, so `movable` and `resize` apply to the standard variant only. This element also writes `--rc-dialog-visual-viewport-left`, `-top`, `-width`, and `-height` onto itself while a fullscreen dialog is open. Those are geometry outputs, not theme inputs: read them if something inside the dialog needs the same measurements, but setting them does not move the dialog, and the component overwrites them on the next viewport change. They are candidates for private `--_rc-dialog-*` names in the next compatible API revision. |