Destructive Confirm Dialog
A type-to-confirm dialog built on native <dialog>, so the focus trap, top layer and escape handling come from the browser. Focus lands on Cancel, and the button says the verb.
Half and full snap points where the content owns the scroll and overscroll-contain keeps the page behind still. The drag handle is a real button, Escape and the backdrop close it, and safe-area padding keeps the last row pressable.
components/bottom-sheet-mobile.tsxnpx hoverlab add bottom-sheet-mobile
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <BottomSheetMobile> — the drawer that comes up from the bottom.
*
* Modals & Drawers had the confirm dialog, the cookie banner, a
* side slide-over, the faceted filter drawer and the share dialog. Every
* one of them is a desktop shape scaled down. On a phone, the pattern
* people actually meet a dozen times a day is the sheet that rises from
* the bottom edge, and none of the five was it.
*
* THE ONE THING THIS GETS RIGHT THAT MOST DO NOT
*
* Snap points are a scroll problem, not an animation problem. A sheet at
* its half height whose content is taller than the gap must scroll *its
* own* content, and the page behind it must not scroll at all — get that
* wrong and dragging inside the sheet quietly moves the list underneath,
* which is the single most common bug in hand-rolled sheets. The content
* region owns the overflow; `overscroll-contain` stops the scroll chain
* reaching the body; and expanding to full height is what changes how
* much is visible, not a taller inner box.
*
* THE HANDLE IS NOT THE ONLY WAY OUT
*
* A drag affordance is invisible to a keyboard and to a screen reader.
* The handle is a real button that toggles the snap point and says which
* one it will move to; Escape closes; the backdrop closes; and there is a
* visible close control, because a sheet whose only dismissal is a
* gesture strands anyone who cannot make it.
*
* THE BOTTOM EDGE IS NOT THE BOTTOM OF THE SCREEN
*
* `env(safe-area-inset-bottom)` is why the last row is reachable on a
* phone with a home indicator. It costs one line and is missing from most
* implementations, where the final button sits under the system gesture
* bar and cannot be pressed.
*
* ACCESSIBILITY: `role="dialog"` + `aria-modal`, labelled by its heading,
* focus moved in on open and returned to the trigger on close, and a
* focus trap so Tab cannot walk into the page behind it.
*/
import * as React from 'react'
import { Check, ChevronUp, X } from 'lucide-react'
export interface SheetOption {
id: string
label: string
hint?: string
}
export interface BottomSheetMobileProps {
title?: string
options?: SheetOption[]
className?: string
}
const DEFAULT_OPTIONS: SheetOption[] = [
{ id: 'relevance', label: 'Most relevant', hint: 'Default' },
{ id: 'newest', label: 'Newest first' },
{ id: 'price-low', label: 'Price: low to high' },
{ id: 'price-high', label: 'Price: high to low' },
{ id: 'rating', label: 'Best rated', hint: 'Four stars and up' },
{ id: 'distance', label: 'Nearest to me', hint: 'Needs location access' },
]
export function BottomSheetMobile({
title = 'Sort and filter',
options = DEFAULT_OPTIONS,
className = '',
}: BottomSheetMobileProps) {
/*
Per-instance prefix for every id this block emits.
The literals these replaced were a latent duplicate the moment the
block appeared twice on one document, and `aria-labelledby` on a
duplicated id resolves to the first match -- so the second copy was
labelled by the first copy's heading. Client component, so `useId` is
the right tool.
*/
const uid = React.useId()
/* Open by default: a sheet demo whose interesting state is shut is a
screenshot of a button. */
const [open, setOpen] = React.useState(true)
const [expanded, setExpanded] = React.useState(false)
const [chosen, setChosen] = React.useState('relevance')
const sheetRef = React.useRef<HTMLDivElement>(null)
const triggerRef = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (!open) {
triggerRef.current?.focus()
return
}
sheetRef.current?.focus()
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpen(false)
return
}
if (event.key !== 'Tab' || !sheetRef.current) return
/* Trap: without this, Tab walks into the page the sheet is covering. */
const focusable = sheetRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (!first || !last) return
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open])
return (
<section className={`mx-auto w-full max-w-sm px-4 py-16 sm:px-6 ${className}`}>
{/*
A phone-shaped frame, because a bottom sheet only makes sense
against an edge. `relative` + `overflow-hidden` make this element
the sheet's viewport instead of the page.
*/}
<div className="relative h-[520px] overflow-hidden rounded-[2rem] border border-border bg-background shadow-sm">
<div className="space-y-3 p-5">
<div className="h-3 w-24 rounded-full bg-muted border border-transparent" />
<div className="h-3 w-40 rounded-full bg-muted border border-transparent" />
<div className="h-28 rounded-xl bg-muted/60 border border-transparent" />
<div className="h-3 w-32 rounded-full bg-muted border border-transparent" />
<div className="h-28 rounded-xl bg-muted/60 border border-transparent" />
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-9 items-center rounded-lg border border-border bg-card px-3 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{title}
</button>
</div>
{open ? (
<>
<button
type="button"
aria-label="Close"
onClick={() => setOpen(false)}
className="absolute inset-0 bg-foreground/40 motion-safe:animate-in motion-safe:fade-in border border-transparent"
/>
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${uid}-sheet-title`}
tabIndex={-1}
className={`absolute inset-x-0 bottom-0 flex flex-col rounded-t-2xl border-t border-border bg-card shadow-lg outline-none transition-[height] duration-300 ${
expanded ? 'h-[92%]' : 'h-[58%]'
}`}
/* The line that makes the last row reachable on a phone. */
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
>
{/* The handle is a button, not a decorative bar. */}
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="group flex w-full shrink-0 flex-col items-center gap-1 rounded-t-2xl px-4 pb-1 pt-2.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
aria-hidden
className="h-1 w-10 rounded-full bg-muted-foreground/40 transition group-hover:bg-muted-foreground/70 border border-transparent"
/>
<span className="sr-only">
{expanded ? 'Collapse the sheet to half height' : 'Expand the sheet to full height'}
</span>
</button>
<header className="flex shrink-0 items-center gap-2 px-5 pb-3 pt-1">
<h2 id={`${uid}-sheet-title`} className="min-w-0 flex-1 text-base font-semibold text-foreground">
{title}
</h2>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="inline-flex h-8 items-center gap-1 rounded-lg px-2 text-xs font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronUp
aria-hidden
className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
{expanded ? 'Half' : 'Full'}
</button>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close sort and filter"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="h-4 w-4" />
</button>
</header>
{/*
The content owns the scroll, and `overscroll-contain` stops
the chain reaching the page behind. This is the bug in most
hand-built sheets.
*/}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3">
<ul role="radiogroup" aria-labelledby={`${uid}-sheet-title`} className="space-y-0.5 pb-2">
{options.map((option) => {
const selected = option.id === chosen
return (
<li key={option.id}>
<button
type="button"
role="radio"
aria-checked={selected}
onClick={() => setChosen(option.id)}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-start transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
selected ? 'bg-primary/10' : 'hover:bg-muted'
}`}
>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{option.label}
</span>
{option.hint ? (
<span className="block text-xs text-muted-foreground">
{option.hint}
</span>
) : null}
</span>
{selected ? (
<Check aria-hidden className="h-4 w-4 shrink-0 text-primary" />
) : null}
</button>
</li>
)
})}
</ul>
</div>
<div className="shrink-0 border-t border-border p-3">
<button
type="button"
onClick={() => setOpen(false)}
className="h-10 w-full rounded-xl bg-primary text-sm font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Show results
</button>
</div>
</div>
</>
) : null}
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/bottom-sheet-mobile.tsx and import it where you need the section:
import { BottomSheetMobile } from '@/components/bottom-sheet-mobile'1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
title | string | 'Sort and filter' |
options | SheetOption[] | DEFAULT_OPTIONS |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Mobile Bottom Sheet — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section class="mx-auto w-full max-w-sm px-4 py-16 sm:px-6 ">
<div class="relative h-[520px] overflow-hidden rounded-[2rem] border border-border bg-background shadow-sm">
<div class="space-y-3 p-5">
<div class="h-3 w-24 rounded-full bg-muted border border-transparent"></div>
<div class="h-3 w-40 rounded-full bg-muted border border-transparent"></div>
<div class="h-28 rounded-xl bg-muted/60 border border-transparent"></div>
<div class="h-3 w-32 rounded-full bg-muted border border-transparent"></div>
<div class="h-28 rounded-xl bg-muted/60 border border-transparent"></div>
<button type="button" class="inline-flex h-9 items-center rounded-lg border border-border bg-card px-3 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">Sort and filter</button>
</div>
<button type="button" aria-label="Close" class="absolute inset-0 bg-foreground/40 motion-safe:animate-in motion-safe:fade-in border border-transparent"></button>
<div role="dialog" aria-modal="true" aria-labelledby="_R_0_-sheet-title" tabindex="-1" class="absolute inset-x-0 bottom-0 flex flex-col rounded-t-2xl border-t border-border bg-card shadow-lg outline-none transition-[height] duration-300 h-[58%]" style="padding-bottom:env(safe-area-inset-bottom, 0px)">
<button type="button" class="group flex w-full shrink-0 flex-col items-center gap-1 rounded-t-2xl px-4 pb-1 pt-2.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span aria-hidden="true" class="h-1 w-10 rounded-full bg-muted-foreground/40 transition group-hover:bg-muted-foreground/70 border border-transparent"></span>
<span class="sr-only">Expand the sheet to full height</span>
</button>
<header class="flex shrink-0 items-center gap-2 px-5 pb-3 pt-1">
<h2 id="_R_0_-sheet-title" class="min-w-0 flex-1 text-base font-semibold text-foreground">Sort and filter</h2>
<button type="button" class="inline-flex h-8 items-center gap-1 rounded-lg px-2 text-xs font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up h-3.5 w-3.5 transition-transform" aria-hidden="true"><path d="m18 15-6-6-6 6"></path></svg>Full</button>
<button type="button" aria-label="Close sort and filter" class="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-4 w-4" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</header>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3">
<ul role="radiogroup" aria-labelledby="_R_0_-sheet-title" class="space-y-0.5 pb-2">
<li>
<button type="button" role="radio" aria-checked="true" class="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-start transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring bg-primary/10">
<span class="min-w-0 flex-1">
<span class="block text-sm font-medium text-foreground">Most relevant</span>
<span class="block text-xs text-muted-foreground">Default</span>
</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-4 w-4 shrink-0 text-primary" aria-hidden="true">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</button>
</li>
<li>
<button type="button" role="radio" aria-checked="false" class="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-start transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:bg-muted">
<span class="min-w-0 flex-1">
<span class="block text-sm font-medium text-foreground">Newest first</span>
</span>
</button>
</li>
<li>
<button type="button" role="radio" aria-checked="false" class="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-start transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:bg-muted">
<span class="min-w-0 flex-1">
<span class="block text-sm font-medium text-foreground">Price: low to high</span>
</span>
</button>
</li>
<li>
<button type="button" role="radio" aria-checked="false" class="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-start transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:bg-muted">
<span class="min-w-0 flex-1">
<span class="block text-sm font-medium text-foreground">Price: high to low</span>
</span>
</button>
</li>
<li>
<button type="button" role="radio" aria-checked="false" class="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-start transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:bg-muted">
<span class="min-w-0 flex-1">
<span class="block text-sm font-medium text-foreground">Best rated</span>
<span class="block text-xs text-muted-foreground">Four stars and up</span>
</span>
</button>
</li>
<li>
<button type="button" role="radio" aria-checked="false" class="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-start transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:bg-muted">
<span class="min-w-0 flex-1">
<span class="block text-sm font-medium text-foreground">Nearest to me</span>
<span class="block text-xs text-muted-foreground">Needs location access</span>
</span>
</button>
</li>
</ul>
</div>
<div class="shrink-0 border-t border-border p-3">
<button type="button" class="h-10 w-full rounded-xl bg-primary text-sm font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Show results</button>
</div>
</div>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Modals & DrawersCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
A type-to-confirm dialog built on native <dialog>, so the focus trap, top layer and escape handling come from the browser. Focus lands on Cancel, and the button says the verb.
Per-category consent where refusing is exactly as easy as accepting, nothing non-essential is pre-ticked, and the page behind it stays readable — the shape the law expects, wired to your own script gating.
A side sheet for editing a record without losing the list behind it — native <dialog> for the trapping, a data attribute for the slide, and a sticky footer so Save never scrolls away.
Facets with per-option counts, zero-result options disabled rather than hidden so the list never jumps, and a pending selection applied on a button that says how many choices changed.
The share modal where a design mistake is a data leak: general access stated as its consequence rather than its setting, and a copy button that never silently widens access.
The interception that keeps work: three answers rather than two, the changed fields listed by name, a destructive-styled discard, and Escape resolving to cancel.
A non-modal drawer that keeps the list on screen, with previous and next controls for moving between records and an activity tab beside the fields.