react-call v2

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 tree
BottomSheet.tsx
import { 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, once
App.tsx
import { BottomSheet } from './BottomSheet'
import { ShareButton } from './ShareButton'
export default function App() {
return (
<>
<BottomSheet />
<ShareButton />
</>
)
}

The caller

anywhere in your app, imperative
ShareButton.tsx
import { 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.

Related examples