Bottom sheet
Slides up from the bottom and back down on close — the mobile-native pattern for action menus and quick choices.
Exit animation
→ no action yet
The Callable
declared once, mounted in the React treeimport { useEffect, useRef, useState } from 'react'import { createCallable } from 'react-call'
interface Action { id: string label: string icon?: string}
interface Props { title: string actions: readonly Action[]}
// Keep the finished call mounted long enough for the slide-down to play.// Must match the CSS transition duration below.const UNMOUNTING_DELAY = 300
export const BottomSheet = createCallable<Props, string | null>( ({ call, title, actions }) => { const sheetRef = useRef<HTMLDivElement>(null)
// Animate in on mount, out once the call has ended. `call.ended` is // true during the unmounting delay — that's the window the exit // transition plays in before react-call drops the call from the stack. // The flag flips in an effect (post-paint), so the browser commits the // off-screen state first and the slide-up has somewhere to animate from. const [entered, setEntered] = useState(false) useEffect(() => setEntered(true), []) const open = entered && !call.ended
useEffect(() => { const onPointer = (e: MouseEvent) => { if (sheetRef.current && !sheetRef.current.contains(e.target as Node)) { call.end(null) } } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') call.end(null) } document.addEventListener('mousedown', onPointer) document.addEventListener('keydown', onKey) return () => { document.removeEventListener('mousedown', onPointer) document.removeEventListener('keydown', onKey) } }, [call])
return ( <div role="dialog" aria-modal="true" aria-label={title} className={`fixed inset-0 z-50 flex items-end justify-center bg-black/50 backdrop-blur-sm transition-opacity duration-300 ${open ? 'opacity-100' : 'opacity-0'}`} > <div ref={sheetRef} className={`w-full max-w-md rounded-t-2xl border-t border-x border-[var(--color-border)] bg-[var(--color-bg)] p-4 shadow-2xl transition-transform duration-300 ease-out ${open ? 'translate-y-0' : 'translate-y-full'}`} > <div aria-hidden="true" className="mx-auto mb-3 h-1 w-10 rounded-full bg-[var(--color-border-strong)]" /> <p className="px-2 py-1 text-sm font-medium text-[var(--color-fg)]"> {title} </p> <ul className="mt-2"> {actions.map((a) => ( <li key={a.id}> <button type="button" onClick={() => call.end(a.id)} className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-left text-sm text-[var(--color-fg)] transition-colors hover:bg-[var(--color-bg-subtle)]" > {a.icon && <span aria-hidden="true">{a.icon}</span>} <span>{a.label}</span> </button> </li> ))} </ul> </div> </div> ) }, UNMOUNTING_DELAY,)BottomSheet.displayName = 'BottomSheet'The Root
mounted in your app tree, onceimport { BottomSheet } from './BottomSheet'import { ShareButton } from './ShareButton'
export default function App() { return ( <> <BottomSheet /> <ShareButton /> </> )}The caller
anywhere in your app, imperativeimport { useState } from 'react'import { BottomSheet } from './BottomSheet'
const ACTIONS = [ { id: 'share', label: 'Share', icon: '⇪' }, { id: 'copy-link', label: 'Copy link', icon: '⤴' }, { id: 'pin', label: 'Pin', icon: '📌' }, { id: 'archive', label: 'Archive', icon: '🗄' },] as const
export const ShareButton = () => { const [last, setLast] = useState<string | null>(null)
const handleClick = async () => { const id = await BottomSheet.call({ title: 'Quick actions', actions: ACTIONS, }) if (id) setLast(id) }
return ( <div className="flex flex-col items-center gap-3"> <button type="button" onClick={handleClick} className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-[var(--color-accent-fg)] transition-colors hover:bg-[var(--color-accent-hover)]" > Quick actions </button> <span className="font-mono text-xs text-[var(--color-fg-subtle)]"> {last ? ( <span className="text-[var(--color-accent)]">→ {last}</span> ) : ( '→ no action yet' )} </span> </div> )}The slide-down on close
The sheet would pop out of existence the moment call.end() runs, with no
chance to slide away. Passing an unmounting delay (the second argument
to createCallable) keeps the finished call mounted for that many
milliseconds and exposes call.ended so you can drive the exit:
const open = entered && !call.ended// open ? 'translate-y-0' : 'translate-y-full'One open flag runs both halves: it flips on the first frame after mount
(slide up), and back to false when call.ended turns true (slide down),
right before react-call drops the call from the stack.
Keep the delay equal to the CSS transition duration — here both are
300ms. See the settings drawer for the same
pattern on a horizontal slide.