Skip to content
Product block

Mobile Bottom Sheet

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.

266 lineslucide-reactAdded 27 Aug 2026Updated 2 Sept 2026
  • bottom sheet
  • mobile
  • drawer
  • snap points
  • safe area

What's included

  • components/bottom-sheet-mobile.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add bottom-sheet-mobile

Or over MCP, from your editor's agent — no account needed.

License

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.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/bottom-sheet-mobile.tsx
'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>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/bottom-sheet-mobile.tsx and import it where you need the section:

import { BottomSheetMobile } from '@/components/bottom-sheet-mobile'

Customize

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.

Props

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.

PropTypeDefault
titlestring'Sort and filter'
optionsSheetOption[]DEFAULT_OPTIONS
classNamestring''

Not using React?

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.

bottom-sheet-mobile.html
<!--
  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>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

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.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

7 more blocks in Modals & Drawers

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 & Drawers

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Modals & Drawers blocks

View category
Open the full page for this block

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.

Modals & Drawers184 lines1 dep
Open the full page for this block

Cookie Consent Banner

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.

Modals & Drawers232 lines1 dep
Open the full page for this block

Slide-Over Edit Panel

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.

Modals & Drawers208 lines1 dep
Open the full page for this block

Faceted Filter Drawer

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.

Modals & Drawers241 lines1 dep
Open the full page for this block

Share & Access Dialog

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.

Modals & Drawers312 lines1 dep
Open the full page for this block

Unsaved Changes Prompt

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.

Modals & Drawers237 lines1 dep
Open the full page for this block

Record Inspector Drawer

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.

Modals & Drawers336 lines1 dep