Mobile

Mobile Search Bar

A phone search field that turns the screen into search mode — Cancel, a scope bar and recent searches — measured against the software keyboard and padded off the notch.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowLeft, Clock, Search, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** Movement (px) on the grab strip before a press becomes a dismiss drag. Below it, a press is still a press. */
const DRAG_START_PX = 4
/** Released past this much downward travel (px), the panel dismisses instead of springing back. */
const DISMISS_PX = 72
/** Fling threshold (px/ms) — a fast flick dismisses from four pixels in. */
const FLING_VELOCITY = 0.5
/** Upward over-drag is damped and capped: the panel moves under the thumb, it never tears off the field. */
const RUBBER_BAND = 0.3

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/mobile-search-bar.json

Prompt

Build a React + TypeScript + Tailwind "MobileSearchBar" component (lucide-react for
four glyphs, no other dependency). It is the phone search bar: a field that turns
the screen into search mode, with a Cancel affordance, a scope bar and a
recent-searches panel that knows where the software keyboard is.

Contract
- export const MobileSearchBar = React.forwardRef<HTMLDivElement,
  MobileSearchBarProps>, plus the types MobileSearchBarVariant /
  MobileSearchBarScope / MobileSearchBarRecent / MobileSearchBarLabels.
- Data
  - MobileSearchBarScope: { value, label }.
  - MobileSearchBarRecent: { id, label, scope? } — id is identity (it is what keeps
    focus in place while the list is edited), label is the query itself, scope is
    the scope that search was run in.
- Props
  - variant?: "inline" | "takeover" | "thumb" (default "inline").
  - value? / defaultValue? / onValueChange? — the query, controlled and uncontrolled.
  - open? / defaultOpen? / onOpenChange? — search mode, both ways. Keep passing
    true and the bar cannot be dismissed; that is the lever for a pinned search screen.
  - scope? / defaultScope? / onScopeChange?, scopes?: MobileSearchBarScope[]
    (default []). An empty list hides the scope row; one scope is legal.
  - recents?: MobileSearchBarRecent[] (default []), onRecentSelect?,
    onRecentRemove?, onClearRecents?. The two optional handlers are also the
    switches: no onRecentRemove means no remove buttons and no Backspace removal,
    no onClearRecents means no Clear all.
  - onSubmit?(query, scope) — the one funnel. Fires for Enter and for tapping a
    recent, never with an empty query, always with the trimmed query.
  - disabled?, disabledReason?, maxPanelHeight? (px, default 320, floored at 96),
    showHandle? (default true), focusOnOpen? (default true), placeholder?
    (default "Search"), label? (default "Search"), labels?:
    Partial<MobileSearchBarLabels> (nine strings), children (extra panel content
    under the history).
  - The root spreads the rest of the native div props and merges className.
- The component owns no data. It never filters, sorts, dedupes or stores a search:
  whatever is in `recents` is what is drawn, so the same slot takes stored history,
  server suggestions, or history matched against the query upstream.

Behavior
- Search mode is not focus. Focusing the field opens it; blurring does NOT close
  it, because tapping a row, scrolling the list and dragging the panel all blur.
  It closes on Cancel, Esc, submit, a press outside (pointerdown, not click) and a
  flick down — five deliberate exits.
- Cancel clears the query and leaves. Clear (the X in the field) empties the query
  and keeps focus, so the keyboard does not drop out from under the next keystroke.
  Enter submits, blurs and closes with the query kept; the field then reads as the
  query that produced what is on screen.
- Esc is two-stage: with text it clears and returns focus to the field, with an
  empty field it leaves search mode. It stops propagation, so one Esc inside a
  screen that also listens settles this layer only.
- Selecting a recent puts its label in the field, restores its scope when it has
  one, fires onRecentSelect and then onSubmit — one funnel, so a host adds to
  history in exactly one place.
- The keyboard is measured, not guessed. While open, subscribe to
  window.visualViewport (resize + scroll) plus window resize/orientationchange,
  coalesce to one commit per rAF, and derive occlusion as
  `layoutHeight - (offsetTop + height) * scale`, clamped at 0 and rounded —
  multiplying by scale is what stops a pinch-zoom from being read as a keyboard.
  The panel's max height is min(maxPanelHeight, room down to the bottom of the
  visible band), floored at 96; the "thumb" variant additionally translates the
  whole bar up by the occlusion once it passes 120px, so it rides on top of the
  keyboard rather than under it. No transition on that lift: the keyboard streams
  its own frames.
- The gesture. Pointer Events only, on the panel's grab strip, which carries
  touch-action: none — so the browser never starts a scroll there and the drag
  never has to preventDefault a passive listener. A press becomes a drag after 4px;
  a gesture more horizontal than vertical is handed back and never re-evaluated.
  setPointerCapture on the element the gesture started on, released on that same
  node. Starting the drag blurs the field (the keyboard leaves the moment the panel
  moves). Movement is written to a transform inside one rAF per frame; upward
  over-drag is damped 0.3 and capped at 32px. Release past 72px, or faster than
  0.5px/ms (velocity smoothed 0.3/0.7 so one jittery frame is not a fling),
  dismisses; anything else springs home over 220ms with
  cubic-bezier(0.32, 0.72, 0, 1). The panel springs home unconditionally, so a
  controlled host that refuses the close is never left with a panel parked halfway
  down the screen.
- Equal paths. Cancel (or the back arrow in "takeover") and Esc do exactly what the
  flick does; Home/End, arrows and Backspace cover the list; the scope row is
  arrow-driven as well as swipeable. Nothing is gesture-only and nothing is
  hover-only.
- Keyboard map. Enter submits (the field is a real <form role="search"> with
  type="search" and enterKeyHint="search", which is what puts a Search key on the
  soft keyboard). ArrowDown from the field enters the history; ArrowUp/ArrowDown
  walk it and ArrowUp on the first row returns to the field; Home/End jump to its
  ends; Backspace/Delete removes the focused row. In the scope row ArrowLeft/Right
  wrap around with selection following focus (APG radiogroup), Home/End jump,
  ArrowDown drops into the history and ArrowUp returns to the field.
- ARIA. <form role="search"> around the field; the input is a real search input
  labelled by an sr-only <label>, with aria-controls pointing at the panel while it
  is open and deliberately no aria-expanded — `searchbox` does not support it, and
  this is a field with a panel beside it, not a combobox. The scope row is a
  role=radiogroup of role=radio
  buttons with roving tabindex — exactly one tab stop, and an unknown incoming
  scope (a stale URL, a facet that was removed) falls back to the first chip so one
  is always selected. Recents are a plain <ul> labelled by the panel heading; each
  row is a button and each remove is its own button whose accessible name carries
  the query. One polite role=status region announces removals, a cleared list and
  the refusal reason, cleared after 2.4s so an identical message can be said again.
  Nothing is announced per keystroke or mid-gesture.
- Refusal. `disabled` makes the field readOnly + aria-disabled and never the native
  disabled attribute, which would blur a control the user is standing on. Focus
  does not open search, `disabledReason` describes the field and is announced when
  it is pressed.
- Focus. Turning `open` on from outside focuses the field (focusOnOpen), but only
  on a transition, never on mount — three bars rendered defaultOpen on one page
  must not fight over the caret. Closing hands focus to the root (tabIndex=-1) when
  focus was inside the component — or nowhere at all, which is where a started drag
  and a Safari button press leave it — and leaves it alone when something outside
  earned it; removing a row hands focus to the row that replaced it, or back to the
  field when the list empties. Never <body>.
- Layout per variant. "inline" keeps the bar in the flow and drops the panel over
  the content as an absolutely positioned card. "takeover" lifts bar and panel into
  an absolute layer filling the nearest positioned ancestor, padded with
  safe-area-inset-top, leaving a 60px placeholder in the flow so the screen behind
  does not jump, and swapping Cancel for a leading back arrow. "thumb" pins the bar
  to the bottom edge of that ancestor, pads with safe-area-inset-bottom and opens
  upward (flex-col-reverse), grab strip at the bottom of the panel. Give the screen
  `relative`; the takeover and thumb variants position against it.
- Edge cases: no recents renders the empty line inside the scroll area (so a short
  panel can still reach it); one recent is legal; a long query truncates in one
  line and the scope tag keeps its width; a long scope label scrolls the chip row
  instead of squashing it; an unknown recent.scope prints as-is rather than
  disappearing; a NaN maxPanelHeight falls back to the default.
- Cleanup: the notice timer, the drag rAF, both viewport listeners, the window
  listeners and the outside-press listener are removed on unmount and whenever open
  or variant changes. prefers-reduced-motion is subscribed with matchMedia (not
  read once) and unsubscribed; under it the entrance animations and the spring-home
  transition are off while every gesture, key and button still works.

Rendering & styling
- Semantic tokens only: bg-background / bg-card / text-card-foreground / bg-muted /
  text-muted-foreground / border / ring-ring. The selected scope INVERTS
  (bg-foreground text-background) instead of taking a colour; nothing on this
  surface is tinted.
- Radius ladder 16/8/4: rounded-2xl panel and thumb bar, rounded-lg field, rows and
  chips, rounded scope tags. Type is small and tight — 13px rows, 12px chips, 11px
  section label, 10px tags — except the input itself, which must be 16px: below
  that, iOS zooms the whole page in when the field takes focus and never zooms out.
- Every hit area is at least 44px (h-11 / min-h-11 / size-11), including the chips,
  the remove buttons and Cancel; nothing depends on hover; focus-visible rings
  everywhere; decorative glyphs are aria-hidden.
- Safe area comes from max(var(--safe-area-inset-<edge>, env(safe-area-inset-<edge>,
  0px)), floor) — the custom property lets a device-frame preview simulate a notch
  and a home indicator on hardware that reports 0.
- cn() merges every className; every transition and animation carries
  motion-reduce:transition-none / motion-reduce:animate-none.

Customization levers
- Structure: drop the scope row (pass no scopes), the handle (showHandle), Clear
  all (omit onClearRecents) or the remove buttons (omit onRecentRemove); `children`
  adds a block under the history — trending queries, a search-the-web row, a
  "searching 4 sources" line.
- Density: the field is h-11 with px-3 and the rows min-h-11 with gap-2.5; drop the
  rows to min-h-10 only if the target audience is not thumb-first. maxPanelHeight
  is the one number that decides how much of the screen the panel claims.
- Motion: SETTLE_MS (220) and the cubic-bezier are the spring; DISMISS_PX (72) and
  FLING_VELOCITY (0.5) make the flick easier or harder; DRAG_START_PX (4) decides
  how much movement is still a tap.
- Keyboard tuning: KEYBOARD_MIN (120) is how much occlusion counts as "the keyboard
  is up" — lower it only if your target device reports a small keyboard, and never
  to 0, or resting viewport jitter will bob the bar.
- Colour: the surface is monochrome by design. If a scope family needs colour,
  spend it on a leading glyph inside the chip and leave the inverted selected state
  alone — that inversion is what marks the one active scope.
- i18n: all nine strings live in `labels`, `placeholder` and `label` are separate,
  and every announcement is built from those strings rather than hardcoded.

Concepts

  • Search mode is not focus — the field opening the panel is not the field owning it. Blur happens constantly on a phone (tapping a row, scrolling, dragging), so blur is not an exit; five deliberate exits are. Getting this backwards is what makes a search screen close under your thumb.
  • Cancel is the missing Escape key — a phone keyboard has no Esc and no discoverable “click somewhere else”, so the way out has to be a 44px button that also clears the query. Every gesture in this component has that button, or a key, doing the same job.
  • Keyboard-aware panel — only the visual viewport shrinks when the software keyboard arrives; 100vh and innerHeight do not move, which is why an unmeasured panel ends up underneath its own keyboard. The occlusion is derived as layoutHeight - (offsetTop + height) * scale, so a pinch-zoom is never mistaken for a keyboard.
  • Thumb-arc bar — the bottom-edge variant sits where the thumb already is, pads with env(safe-area-inset-bottom) to clear the home indicator, opens upward, and rides on top of the keyboard when it appears. It is the one layout that has no desktop equivalent at all.
  • Consumer-owned history — the component draws recents and reports intent; it never matches, sorts or stores. That is what lets the same slot hold local history, server suggestions or fuzzy matches without the component pretending to be a search engine.
  • Selection follows focus — the scope row is an APG radiogroup: one tab stop, arrows move and choose in the same keystroke, and an unknown incoming scope falls back to the first chip so the row is never left with nothing selected and no tab stop.

On This Page