Bottom Sheet
A sheet rises from the bottom edge over a dimmed screen, the way mobile apps present quick choices.
Share to
Create a mobile-style bottom sheet in React and CSS. A scrim covers the screen with opacity 0 and pointer-events none, and the sheet sits below the bottom edge with transform: translateY(100%). When open, fade the scrim to opacity 1 over 240ms and slide the sheet to translateY(0) over 380ms with cubic-bezier(0.32, 0.72, 0, 1), which starts fast and settles gently like native sheets. Give the sheet rounded top corners, a small grab handle, a title, and a list of actions. Close it on a scrim click, on Escape, and on any action. Move focus into the sheet when it opens and back to the trigger when it closes, and set aria-modal and role="dialog". Under prefers-reduced-motion, fade without the slide. The sheet title reads "Share to".
Source
'use client'
import { useEffect, useId, useRef, useState } from 'react'
import s from './demo.module.css'
export default function BottomSheet({ labels }: { labels: Record<string, string> }) {
const [open, setOpen] = useState(false)
const sheet = useRef<HTMLDivElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const titleId = useId()
useEffect(() => {
if (open) sheet.current?.focus()
}, [open])
const close = () => {
setOpen(false)
trigger.current?.focus()
}
return (
<div className={s.screen} data-open={open || undefined} onKeyDown={(e) => e.key === 'Escape' && open && close()}>
<button ref={trigger} type="button" className={s.pill} onClick={() => setOpen(true)}>
{labels.open}
</button>
<div className={s.scrim} onClick={close} aria-hidden="true" />
<div
ref={sheet}
className={s.sheet}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
inert={!open}
>
<span className={s.handle} aria-hidden="true" />
<p id={titleId} className={s.title}>
{labels.title}
</p>
{[labels.a, labels.b, labels.c].map((t) => (
<button key={t} type="button" className={s.action} onClick={close}>
{t}
</button>
))}
</div>
</div>
)
}
.screen {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
}
.pill {
height: 40px;
padding: 0 18px;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-sm);
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.pill:hover { border-color: var(--stage-accent); }
.pill:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.scrim {
position: absolute;
inset: 0;
background: oklch(0% 0 0 / 0.55);
opacity: 0;
pointer-events: none;
transition: opacity 240ms var(--ease-out);
}
.sheet {
position: absolute;
left: 50%;
bottom: 0;
display: grid;
gap: 2px;
width: min(320px, 100%);
padding: 8px 12px 14px;
border-radius: 18px 18px 0 0;
background: var(--stage-surface);
box-shadow: 0 -10px 30px oklch(0% 0 0 / 0.35);
outline: none;
transform: translate(-50%, 100%);
transition: transform 380ms cubic-bezier(0.32, 0.72, 0, 1);
}
.screen[data-open] .scrim { opacity: 1; pointer-events: auto; }
.screen[data-open] .sheet { transform: translate(-50%, 0); }
.handle {
justify-self: center;
width: 36px;
height: 4px;
margin-bottom: 6px;
border-radius: 2px;
background: var(--stage-rule);
}
.title {
margin: 0 0 4px;
padding: 0 8px;
font: 600 var(--text-sm) / 1.4 var(--font-display);
color: var(--stage-ink);
}
.action {
height: 38px;
padding: 0 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--stage-ink);
font: 400 var(--text-sm) / 1 var(--font-body);
text-align: left;
cursor: pointer;
}
.action:hover { background: var(--stage-bg); }
.action:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
@media (prefers-reduced-motion: reduce) {
.sheet { transition: opacity 200ms; transform: translate(-50%, 0); opacity: 0; }
.screen[data-open] .sheet { opacity: 1; }
}