Inline Search

Search needs room to type and the browse filters are already using it, so one has to give — and sending search to its own screen takes the filters away with it.

NavigationSearchCommerceMediaintermediate
  • Keyboard
  • Touch
  • Responsive
  • Reduced motion
npx shadcn@latest add https://design-recreation.vercel.app/r/inline-search.json

The problem

Search needs room to type. The browse filters are already using that room. Put them side by side and both are cramped; move search to its own screen and the filters go away with it, so choosing "Popular" again means navigating back.

Handing the space over solves both: the field grows out of its own icon while the filters collapse into the dismiss button. One bar, two jobs, and the control that undoes the state sits exactly where the thing it replaced was.

Use cases

  • A discovery or browse header with two or three views plus search
  • A media, shop or feed toolbar on mobile, where a second row is not free
  • Any filter strip that needs search without a separate search screen

Not for: search that returns a rich result list needing its own surface — use a command palette. Not for more than three filters: they have to fit beside a 250px field on a phone, and four will not. Not for filters that must stay visible while searching — this pattern's whole trade is that they do not.

This is one of the library's inline family, with inline-confirm and inline-overflow. They share one idea: resolve the interaction in the surface that started it rather than opening another one.

Installation

bash
npm install motion lucide-react

Then copy InlineSearch.tsx. It depends only on @/lib/cn.

Usage

tsx
import InlineSearch from "@/patterns/inline-search/InlineSearch";
import { Flame, Heart } from "lucide-react";

<InlineSearch
  filtersLabel="Browse"
  defaultFilter="favorites"
  onFilterChange={(id) => setView(id)}
  onQueryChange={(q) => setQuery(q)}
  filters={[
    {
      id: "popular",
      label: "Popular",
      icon: <Flame fill="currentColor" />,
      activeClassName: "bg-rose-100 dark:bg-rose-500/15",
      accentClassName: "text-rose-600 dark:text-rose-400",
    },
    {
      id: "favorites",
      label: "Favorites",
      icon: <Heart fill="currentColor" />,
      activeClassName: "bg-rose-100 dark:bg-rose-500/15",
      accentClassName: "text-rose-600 dark:text-rose-400",
    },
  ]}
/>;

Colour is a prop, not a built-in. The original hardcoded a pink gradient and a red label, which is a design decision the pattern has no business making — and one that had no dark-mode answer.

API

PropTypeDefaultDescription
filtersreadonly SearchFilter[]—Two or three. Empty renders nothing.
filtersLabelstring—Names the group for assistive technology.
defaultFilterstringfirst filterUncontrolled initial selection.
filterstringuncontrolledControlled selection. Pass with onFilterChange.
onFilterChange(id: string) => void—Fires on every selection.
defaultQuerystring""Uncontrolled initial query.
querystringuncontrolledControlled query. Pass with onQueryChange.
onQueryChange(query: string) => void—Fires on every keystroke and on dismiss.
placeholderstring"Search…"Field placeholder.
searchLabelstring"Search"Accessible name for the trigger and the field.
closeLabelstring"Close search"Accessible name for the dismiss button.
expandedWidthnumber250Width the field opens to, in px.
defaultOpenbooleanfalseUncontrolled initial state.
openbooleanuncontrolledControlled state — useful for a / shortcut.
onOpenChange(open: boolean) => void—Fires on every toggle.

SearchFilter: { id: string; label: string; icon: ReactNode; activeClassName?: string; accentClassName?: string }.

Keyboard

KeyAction
TabEnters the bar. The filter group is one stop, not one per filter
← → ↑ ↓Move between filters, selecting as they go, wrapping
Home / EndFirst or last filter
Enter / SpaceOpen search, or dismiss it
EscapeFrom the field: clear, close, and return focus to the trigger

Accessibility

Nothing in the bar had a name. The trigger, the dismiss button and the field were all unlabelled — three controls that announced as "button", "button" and "search". All three are named now, and the trigger carries aria-expanded and aria-controls pointing at the field.

The filters were plain buttons with no grouping and no selected state, so a screen reader user could hear "Popular" and "Favorites" without being told either was a choice or which one was active. They are an APG radiogroup with roving tabindex now.

Focus follows the trade. Opening moves focus into the field — the field is what the trigger promised. Dismissing returns focus to the trigger, which is where the dismiss button was standing. Without that second half, closing search drops focus to the body and a keyboard user restarts from the top of the page.

Dismissing clears the query, matching the browser's own search fields: the field disappearing with a query still in it leaves results filtered by something the user can no longer see.

The filter buttons fill the bar's full 48px height rather than the 40px their padding gives them. The visible pill is still 40px — it is inset inside the target — so the look is the original's and the touch target clears the 44px minimum.

Under prefers-reduced-motion both pills resize instantly, the field cross-fades instead of sliding, and the shared-layout pill jumps between filters. The bar still does everything it did.

Narrow containers. Below about 300px the filter labels are dropped and the strip goes icon-only, because a clipped "Favori…" is worse than the icon alone. The buttons keep their aria-label in both tiers, so nothing is lost to assistive tech, and the selected pill still shows which filter is active. This is a container query rather than a media query: the bar is as likely to sit in a narrow column on a desktop as on a phone, and it is the space it actually has that decides what fits.

Opening search on a narrow bar shrinks the field rather than the dismiss button, which stays a full 48px circle on screen. The one control that undoes the state you are in is the last thing that should be pushed off the edge.

Performance

transform and opacity everywhere except the two pill widths, which are the animation — one grows as the other shrinks, and that handover is the pattern. They are siblings on one line, so each reflow is contained to that row.

The original carried willChange: "width, transform, opacity" as a permanent inline style on five elements, alongside translateZ(0) and backfaceVisibility: hidden. Permanent will-change is the documented anti-pattern: it holds a compositor layer for the entire life of the page to buy a few frames during a 400ms animation, and will-change: width buys nothing at all, because width is laid out rather than composited. All of it is gone.

The type="search" field keeps its font-size at 16px, below which iOS Safari zooms the page on focus — and having the viewport lurch as the field opens undoes the smoothness the rest of this is for.

Source

The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.

InlineSearch.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
  AnimatePresence,
  LayoutGroup,
  motion,
  useReducedMotion,
} from "motion/react";
import { Search, X } from "lucide-react";

import { cn } from "@/lib/cn";

/* -------------------------------------------------------------------------- */
/* Types                                                                       */
/* -------------------------------------------------------------------------- */

export interface SearchFilter {
  id: string;
  label: string;
  icon: React.ReactNode;
  /** Applied to the moving pill while selected, e.g. `"bg-rose-100"`. */
  activeClassName?: string;
  /** Applied to the icon and label while selected, e.g. `"text-rose-600"`. */
  accentClassName?: string;
}

export interface InlineSearchProps extends Omit<
  React.ComponentPropsWithoutRef<"div">,
  "onChange" | "defaultValue"
> {
  /** Two or three. They collapse to nothing while searching, so keep them cheap. */
  filters: readonly SearchFilter[];
  /** Names the filter group, e.g. "Browse". */
  filtersLabel: string;
  defaultFilter?: string;
  filter?: string;
  onFilterChange?: (id: string) => void;

  defaultQuery?: string;
  query?: string;
  onQueryChange?: (query: string) => void;

  placeholder?: string;
  /** Accessible name for the trigger and the field. @default "Search" */
  searchLabel?: string;
  /** Accessible name for the dismiss button. @default "Close search" */
  closeLabel?: string;
  /** Width the field opens to, in px. @default 250 */
  expandedWidth?: number;

  defaultOpen?: boolean;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
}

/* -------------------------------------------------------------------------- */
/* Motion — kept verbatim from the original                                    */
/* -------------------------------------------------------------------------- */

/*
 * 200/19 is damping ratio 0.67. The bar is two pills trading one pool of
 * space, and at this ratio the one that grows arrives slightly before the one
 * that shrinks has finished — which is what makes it read as a handover rather
 * than as two independent resizes.
 */
const elastic = {
  type: "spring" as const,
  stiffness: 200,
  damping: 19,
  mass: 1,
};

/** 300/25 is ratio 0.72. Drives the shared-layout pill between filters. */
const pillSpring = { type: "spring" as const, stiffness: 300, damping: 25 };

const COLLAPSED_WIDTH = 48;
const RADIUS = 28;

/* -------------------------------------------------------------------------- */
/* Component                                                                   */
/* -------------------------------------------------------------------------- */

/**
 * A bar where search and the browse filters trade the same strip of space.
 *
 * Search needs room to type and the filters are already using it. Putting them
 * side by side makes both cramped; putting search on another screen loses the
 * filters entirely. Handing the space over — filters collapsing to a dismiss
 * button as the field opens — keeps one bar doing both jobs.
 */
export function InlineSearch({
  filters,
  filtersLabel,
  defaultFilter,
  filter: controlledFilter,
  onFilterChange,
  defaultQuery = "",
  query: controlledQuery,
  onQueryChange,
  placeholder = "Search…",
  searchLabel = "Search",
  closeLabel = "Close search",
  expandedWidth = 250,
  defaultOpen = false,
  open: controlledOpen,
  onOpenChange,
  className,
  ...rest
}: InlineSearchProps) {
  const reduce = useReducedMotion();
  const uid = useId();
  const fieldId = `${uid}-field`;

  const inputRef = useRef<HTMLInputElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const filterRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const interacted = useRef(false);

  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
  const open = controlledOpen ?? uncontrolledOpen;

  const [uncontrolledQuery, setUncontrolledQuery] = useState(defaultQuery);
  const query = controlledQuery ?? uncontrolledQuery;

  const [uncontrolledFilter, setUncontrolledFilter] = useState(
    defaultFilter ?? filters[0]?.id,
  );
  const selected = controlledFilter ?? uncontrolledFilter;

  const setQuery = useCallback(
    (next: string) => {
      if (controlledQuery === undefined) setUncontrolledQuery(next);
      onQueryChange?.(next);
    },
    [controlledQuery, onQueryChange],
  );

  const setOpen = useCallback(
    (next: boolean) => {
      interacted.current = true;
      if (controlledOpen === undefined) setUncontrolledOpen(next);
      onOpenChange?.(next);
    },
    [controlledOpen, onOpenChange],
  );

  const close = useCallback(() => {
    // Clearing on dismiss matches the browser's own search fields: the field
    // going away with a query still in it leaves the results filtered by
    // something the user can no longer see.
    setQuery("");
    setOpen(false);
  }, [setOpen, setQuery]);

  const selectFilter = useCallback(
    (id: string) => {
      if (controlledFilter === undefined) setUncontrolledFilter(id);
      onFilterChange?.(id);
    },
    [controlledFilter, onFilterChange],
  );

  /*
   * The field is what the trigger promised, so focus has to follow it in —
   * and the trigger is what the dismiss button replaced, so focus has to come
   * back out. `interacted` keeps this from stealing focus on mount.
   */
  useEffect(() => {
    if (!interacted.current) return;
    if (open) inputRef.current?.focus();
    else triggerRef.current?.focus();
  }, [open]);

  /** APG radiogroup: arrows move selection and focus together, wrapping. */
  const handleFilterKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
      const keys = [
        "ArrowRight",
        "ArrowDown",
        "ArrowLeft",
        "ArrowUp",
        "Home",
        "End",
      ];
      if (!keys.includes(event.key)) return;
      event.preventDefault();

      const last = filters.length - 1;
      const next =
        event.key === "ArrowRight" || event.key === "ArrowDown"
          ? (index + 1) % filters.length
          : event.key === "ArrowLeft" || event.key === "ArrowUp"
            ? (index - 1 + filters.length) % filters.length
            : event.key === "Home"
              ? 0
              : last;

      selectFilter(filters[next].id);
      filterRefs.current[next]?.focus();
    },
    [filters, selectFilter],
  );

  if (filters.length === 0) return null;

  return (
    <LayoutGroup>
      <div
        className={cn(
          // A container query, not a viewport one: this bar is as likely to sit
          // in a narrow column on a wide screen as on a phone, and it is the
          // space it has that decides what fits.
          "@container flex w-full min-w-0 justify-center p-4",
          className,
        )}
        {...rest}
      >
        <div className="flex min-w-0 items-center gap-3">
          {/* Search pill */}
          <motion.div
            role="search"
            initial={false}
            animate={{ width: open ? expandedWidth : COLLAPSED_WIDTH }}
            transition={reduce ? { duration: 0.01 } : elastic}
            className={cn(
              "relative flex h-12 items-center overflow-hidden bg-surface shadow-resting",
              // Closed, this is a circle, and a circle that flex has squeezed
              // by 9px reads as broken rather than as tight. Open, it is a
              // rectangle and can give up width happily — which is what keeps
              // the dismiss button on screen instead of off the edge.
              open ? "min-w-0 shrink" : "shrink-0",
            )}
            style={{ borderRadius: RADIUS }}
          >
            <motion.button
              ref={triggerRef}
              type="button"
              layout="position"
              aria-label={searchLabel}
              aria-expanded={open}
              // Only while the field exists — `aria-controls` pointing at an
              // id that is not in the document is a dangling reference.
              aria-controls={open ? fieldId : undefined}
              onClick={() => setOpen(!open)}
              // Blurs as it shrinks. Unusual, and kept — it is the one bit of
              // this bar that acknowledges the press rather than the result.
              whileTap={
                reduce ? undefined : { scale: 0.9, filter: "blur(0.8px)" }
              }
              transition={reduce ? { duration: 0.01 } : elastic}
              className="focus-ring z-10 mx-auto flex size-12 shrink-0 items-center justify-center"
              style={{ borderRadius: RADIUS }}
            >
              <Search
                className="size-6 text-content-muted"
                strokeWidth={2.5}
                aria-hidden="true"
              />
            </motion.button>

            <AnimatePresence mode="popLayout">
              {open && (
                <motion.input
                  key="field"
                  ref={inputRef}
                  id={fieldId}
                  type="search"
                  aria-label={searchLabel}
                  placeholder={placeholder}
                  value={query}
                  onChange={(event) => setQuery(event.target.value)}
                  onKeyDown={(event) => {
                    if (event.key === "Escape") {
                      event.preventDefault();
                      close();
                    }
                  }}
                  initial={reduce ? { opacity: 0 } : { opacity: 0, x: -20 }}
                  animate={reduce ? { opacity: 1 } : { opacity: 1, x: 0 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, x: -20 }}
                  transition={{ duration: reduce ? 0.01 : 0.4 }}
                  // 16px or larger, so iOS Safari does not zoom the page on focus.
                  className={cn(
                    "h-full w-full min-w-0 border-none bg-transparent pr-4 text-base",
                    "text-content placeholder-content-subtle outline-none",
                    "[&::-webkit-search-cancel-button]:hidden",
                  )}
                />
              )}
            </AnimatePresence>
          </motion.div>

          {/* Filters, or the dismiss button that replaced them */}
          <motion.div
            layout
            transition={reduce ? { duration: 0.01 } : elastic}
            className="relative flex h-12 shrink-0 items-center overflow-hidden bg-surface shadow-resting"
            style={{ borderRadius: RADIUS }}
          >
            <AnimatePresence mode="popLayout" initial={false}>
              {open ? (
                <motion.button
                  key="dismiss"
                  type="button"
                  aria-label={closeLabel}
                  onClick={close}
                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
                  animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
                  transition={
                    reduce ? { duration: 0.01 } : { ...elastic, delay: 0.1 }
                  }
                  whileTap={reduce ? undefined : { scale: 0.85 }}
                  className="focus-ring flex size-12 items-center justify-center"
                  style={{ borderRadius: RADIUS }}
                >
                  <X
                    className="size-5 text-content-muted"
                    strokeWidth={2.5}
                    aria-hidden="true"
                  />
                </motion.button>
              ) : (
                <motion.div
                  key="filters"
                  role="radiogroup"
                  aria-label={filtersLabel}
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                  transition={reduce ? { duration: 0.01 } : undefined}
                  className="flex h-full items-center gap-1 px-1.5"
                >
                  {filters.map((entry, index) => {
                    const isSelected = entry.id === selected;
                    return (
                      <button
                        key={entry.id}
                        ref={(node) => {
                          filterRefs.current[index] = node;
                        }}
                        type="button"
                        role="radio"
                        aria-checked={isSelected}
                        // Carried always, so the name survives the narrow tier
                        // below where the visible label is dropped. It matches
                        // the visible text exactly, so voice control still
                        // works when the label is showing.
                        aria-label={entry.label}
                        // Roving tabindex: the group is a single tab stop.
                        tabIndex={isSelected ? 0 : -1}
                        onClick={() => selectFilter(entry.id)}
                        onKeyDown={(event) => handleFilterKeyDown(event, index)}
                        className={cn(
                          // Full height, not `py-2.5`. The visible pill is 40px
                          // tall, which is under the 44px touch minimum — so
                          // the button fills the bar and the pill is inset
                          // instead, leaving the look alone and the target
                          // legal.
                          "focus-ring relative h-full px-3 text-sm font-medium",
                          // The original's roomier padding, kept for the width
                          // it was designed at rather than forced on every one.
                          "@min-[420px]:px-5",
                          "whitespace-nowrap transition-colors",
                          isSelected
                            ? "text-content"
                            : "text-content-subtle hover:text-content-muted",
                        )}
                        style={{ borderRadius: RADIUS }}
                      >
                        {isSelected && (
                          <motion.span
                            layoutId="inline-search-filter"
                            aria-hidden="true"
                            initial={false}
                            transition={
                              reduce ? { duration: 0.01 } : pillSpring
                            }
                            className={cn(
                              "absolute inset-x-0 inset-y-1",
                              entry.activeClassName ?? "bg-surface-subtle",
                            )}
                            style={{ borderRadius: RADIUS }}
                          />
                        )}
                        <span
                          className={cn(
                            "relative z-10 flex items-center justify-center gap-1 font-semibold",
                            isSelected ? entry.accentClassName : undefined,
                          )}
                        >
                          <span aria-hidden="true" className="[&>svg]:size-5">
                            {entry.icon}
                          </span>
                          {/*
                            Dropped rather than truncated when the bar is too
                            narrow for both filters and the search button. A
                            clipped "Favori…" is worse than the icon alone, and
                            the accessible name on the button is unaffected.
                          */}
                          <span className="hidden @min-[300px]:inline">
                            {entry.label}
                          </span>
                        </span>
                      </button>
                    );
                  })}
                </motion.div>
              )}
            </AnimatePresence>
          </motion.div>
        </div>
      </div>
    </LayoutGroup>
  );
}

export default InlineSearch;

Technologies

FrameworkReact 19
MotionMotion 12 (motion/react), LayoutGroup + layoutId
StylingTailwind CSS v4
Iconslucide-react
TypesTypeScript 5.9, strict

Credits

Original design by nitishkmrk. Renamed from Discovery Bar, which named the place it might sit rather than what it does. Rebuilt with a props API, named controls, an APG radiogroup, focus hand-off in both directions and themeable accents. The 200/19 elastic spring, the 100ms delay before the dismiss button arrives, and the press that blurs as it shrinks are all unchanged.