Skip to main content
ASoc
Tutorial

Tailwind Search Bar: Accessible React Input, Icon and Live Filtering

Build a Tailwind CSS v4 search bar with a real React example: icon positioning, accessible labels, keyboard focus, live filtering and empty states.

The ASoc Team10 min read

A Tailwind search bar needs more than a rounded input and a magnifying-glass icon. Give the field an accessible name, make the icon decorative, preserve a visible keyboard focus state, and decide whether typing filters existing results or submits a query. Here is a working React pattern from a Next.js storefront, plus the trade-offs behind it.

The search bar we actually ship

The ASoc account dashboard has a search field above a responsive grid of purchased templates. It lives in src/components/organisms/YourProductsGrid.tsx. The input filters an already-authorized array of products in memory; it does not query an API, navigate to a results page, or ask the server for more records.

This matters because a search input is a UI component, while search behavior is an application decision. Most snippets on a component gallery stop after the icon and border. A production component has to answer what happens after someone types, clears the field, tabs away, or finds nothing.

The visual portion is small enough to show in full. This is the shipped JSX, with the existing state setter from the same component:

const [searchText, setSearchText] = useState("");

<div className="relative min-w-[220px] flex-1">
  <Search
    className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-text-color-secondary dark:text-gray-400"
    aria-hidden="true"
  />
  <input
    type="text"
    value={searchText}
    onChange={(e) => setSearchText(e.target.value)}
    placeholder="Search your products…"
    aria-label="Search your products"
    className="w-full rounded-lg border border-stroke-secondary bg-white py-2.5 pr-4 pl-10 text-sm text-text-color outline-none focus:border-primary dark:border-gray-700 dark:bg-gray-800 dark:text-white/80"
  />
</div>

Search comes from lucide-react, and the container is inside a flex flex-wrap toolbar alongside category chips. These are real classes from the project, not a mockup built only for this article.

Why each Tailwind class is there

Class or groupJobWhat breaks if you remove it
relative on the wrapperEstablishes the icon's positioning contextThe icon may anchor to another ancestor
absolute top-1/2 left-3 -translate-y-1/2Centers the icon verticallyThe icon drifts when the input height changes
pointer-events-noneLets clicks on the icon focus the input beneath itThe decorative SVG can intercept pointer events
pl-10Reserves room for the left iconText overlaps the icon
min-w-[220px] flex-1Shares toolbar space while keeping a usable widthA narrow sibling can squeeze the input too far
w-full rounded-lg border py-2.5Gives the field a stable box and hit areaThe field is difficult to see or target
focus:border-primaryMakes focus visible on the borderThe field loses its custom focus cue
dark:*Keeps text, surface and border legible in dark modeThe field can disappear into the dark panel

The min-w-[220px] value is a product choice, not a Tailwind default. The parent wraps on small widths, so the search field can take a row when the filter chips no longer fit. On an especially narrow viewport, test the whole toolbar instead of assuming a minimum width can never overflow.

Three search bar patterns, and when to use each

PatternTriggerGood fitMain trade-off
Live filterEvery onChangeA small, already-loaded listResults change while typing; needs an empty state
Submit formEnter or Search buttonServer-side search or shareable query URLAdds an explicit action and usually a loading state
ComboboxTyping and selecting a suggestionAutocomplete, commands, entitiesRequires keyboard navigation, active-option state and ARIA semantics

The ASoc dashboard deliberately uses live filtering. It already has the authorized product list, so a network request per keystroke would add latency without improving the answer. A public product catalog can make a different choice: when a query needs ranking, pagination or a server index, a real form with a GET query string is easier to share and navigate.

Do not turn a simple search field into a combobox just because it has an icon. A suggestion popup needs Arrow Up/Down, Enter, Escape, a highlighted active option and clear announcement semantics. Those are features, not styling classes.

The filtering function is separate from the input

The second real source file is src/lib/productFilter.ts. Its matching function is a pure predicate:

export function matchesProductFilter(
  product: ProductDownloadGroupData,
  activeCategory: ProductCategory | "all",
  searchText: string,
): boolean {
  if (product.category === null) return true;
  if (activeCategory !== "all" && product.category !== activeCategory) {
    return false;
  }
  const query = searchText.trim().toLowerCase();
  if (query === "") return true;
  const nameMatch = product.productName.toLowerCase().includes(query);
  const taglineMatch = (product.tagline ?? "").toLowerCase().includes(query);
  return nameMatch || taglineMatch;
}

That function has no JSX and no Tailwind classes. It is tested separately in src/lib/__tests__/productFilter.test.ts. This separation is why the same search logic could be used behind a different visual design without changing its behavior.

Two details are worth copying. First, trimming and lowercasing happen once per predicate call, and an empty query returns the unfiltered set. Second, the search matches the product name and tagline—the text a result card actually displays. Matching invisible description text would return apparently unrelated cards, leaving users unable to understand why they matched.

One exception is deliberate: the backend starter row has category: null and always stays visible. It is not a template and should not disappear when someone filters template categories. That is a product rule, not a Tailwind rule; do not copy it blindly into a general-purpose search component.

The component combines the predicate with the selected category and the input value:

const filtered = useMemo(
  () =>
    products.filter((p) =>
      matchesProductFilter(p, activeCategory, searchText),
    ),
  [products, activeCategory, searchText],
);

For a short list, useMemo may not be necessary for speed. Here it makes the derived result depend explicitly on the list, category and query. Do not assume it is a performance win without profiling. If the collection becomes large, measure filtering and rendering separately: a fast predicate cannot make hundreds of mounted cards cheap.

For the broader search architecture, including why the public catalog keeps data in the browser, see searching a Next.js storefront without a search service. That article owns the data and bundle decision; this one owns the search control's interaction and presentation.

Accessible naming is not optional

A placeholder is a hint, not a label. It vanishes as soon as someone types and may be insufficiently clear to assistive technology. The shipped input has aria-label="Search your products", so its accessible name survives typing.

A visible <label> is usually better when the layout allows it. The following is an illustrative alternative, not a second component shipped by ASoc:

<label htmlFor="template-search" className="block text-sm font-medium">
  Search templates
</label>
<div className="relative mt-2">
  <Search
    aria-hidden="true"
    className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
  />
  <input
    id="template-search"
    type="search"
    name="q"
    className="w-full rounded-lg border border-gray-300 bg-white py-3 pr-4 pl-10 text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
  />
</div>

The type="search" choice expresses the field's purpose and may expose browser-specific clear controls. It does not implement search, and browsers differ in their appearance. The existing dashboard uses type="text" because it is a controlled filter, which is also valid when its purpose is clearly named.

The icon has aria-hidden="true" because it communicates nothing the accessible name does not already say. It also has pointer-events-none so clicking the visual icon hits the input. If the icon is meant to submit a query, make it a real <button type="submit"> with an accessible name; a decorative SVG is not an action.

Keyboard focus and Tailwind v4

The shipped field uses outline-none focus:border-primary. It changes the border on focus, but removing the browser outline puts more responsibility on the replacement cue. For a new component, prefer a clear outline or ring with sufficient contrast, and test Windows forced-colors mode as well as ordinary light and dark themes.

In Tailwind CSS v4, outline-none really removes the outline. outline-hidden preserves a transparent outline for forced-colors scenarios, while an explicit focus-visible:outline-2 can give the user a visible outline without depending on a box-shadow. Tailwind documents the difference in its outline-style reference. The illustrated field above uses a visible outline; it is a proposed improvement, not a claim that the dashboard has already been changed.

For why buttons use a different focus pattern from text inputs, read Tailwind focus-visible and keyboard navigation. The search input needs an obvious insertion point even after a mouse click; a button's focus heuristic can be different.

Empty results should explain what to do next

The dashboard does not simply render an empty grid. It computes whether there are any template products, whether any remain visible, and whether a filter is active. Only when the last two conditions warrant it does the UI show:

No products match — try clearing the search or filter.

That conditional lives in src/components/organisms/YourProductsGrid.tsx alongside the field, not in the pure matching function. A search field is incomplete if it can silently erase all results without telling the user why.

For a public search page, consider a visible result count such as “4 templates found.” If results update while typing, a politely announced status may help screen-reader users, but avoid a live region that repeats the entire result list on every keystroke. Test announcements in an actual screen reader rather than assuming an ARIA attribute alone guarantees good behavior.

If you use a submit form, show the query in the URL and preserve it in the input after navigation. A shareable ?q=dashboard result is usually more useful than an opaque in-memory state for public discovery pages. The ASoc account dashboard does not need that: it searches a private, already-loaded list, and the query is not a public destination.

The toolbar uses flex flex-wrap items-center gap-3. The search wrapper is flex-1 with a minimum width, while category buttons are in a wrapping group. This allows the input and filters to share a row when space permits and to wrap naturally otherwise. There is no separate “mobile search” implementation and therefore no second query state to keep in sync.

The results use grid gap-5 sm:grid-cols-2 lg:grid-cols-3. The input's width and the grid's column count are independent. This is important: the search control should remain usable even when the cards switch from three columns to one.

A common alternative is to hide the input behind a search icon on phones. That can be appropriate for a crowded navigation header, but it adds a disclosure button, expanded state, focus movement and an Escape/close interaction. Do not add that complexity to a dashboard toolbar that has room to wrap.

SymptomLikely causeFix
Typed text sits under the iconInput lacks left paddingPair left-3 with enough pl-* padding
Clicking the icon does not focus the fieldSVG intercepts pointer eventsUse pointer-events-none on a decorative icon
Input loses focus visibilityOutline removed without a clear replacementAdd a visible focus-visible:outline-* or tested focus ring
Placeholder is the only nameNo label or aria-labelGive the input a persistent accessible name
Search seems to return random cardsPredicate matches fields not shown on cardsSearch visible fields or show match context
No matches looks like a broken pageGrid renders zero cards without feedbackAdd a conditional empty state and recovery action
Search overflows on a phoneFixed width or non-wrapping toolbarTest flex-wrap and the field's minimum width
Search fires an API request per keypressLive-filter design copied to a server indexUse an explicit submit form or deliberate debouncing
Result cards are slow despite fast matchingToo many cards render at onceProfile rendering; consider pagination or virtualization

FAQ

Should a Tailwind search bar use type="search" or type="text"?

Both can work. type="search" communicates the purpose to the browser and may offer a built-in clear affordance; type="text" is also valid when the field is properly labeled. Neither type provides filtering logic.

Does Tailwind CSS include search functionality?

No. Tailwind styles the field, icon and focus states. React state, a pure predicate, a URL query or a server search endpoint decides what the query means. Keep those concerns separate.

Do I need a search button?

Use one when the query is submitted, especially to a URL or API. A live filter over a small local list can work without it. Do not make a decorative icon look clickable unless it actually performs an action.

Should I debounce every search input?

Not by default. The shipped dashboard filters an already-loaded array synchronously. Debouncing is useful when queries trigger expensive work or network requests, but it can make a tiny local list feel sluggish for no gain.

Try the pattern in a real template

The same approach—accessible inputs, responsive Tailwind layout and a clear empty state—fits ASoc Brief, ASoc Byte and ASoc Canvas landing-page projects. These are template destinations, not a claim that each ships this dashboard search component.

Browse Tailwind landing page templates or Next.js landing page templates. For the general focus-state rules behind this field, see Tailwind focus-visible; for the data-side search trade-offs, see Next.js storefront search.

Technical references: Tailwind hover, focus and state variants, Tailwind outline styles, and MDN search input.

Keep reading

Tutorial10 min read

Tailwind Sizes: One Variable Behind 435 Uses of w-, h- and size-

Every numeric size is calc(var(--spacing) * n). Measured across 139 files: which families get used, the fractional steps, and the sizes that aren't classes.

Read more
Tutorial11 min read

Tailwind Translate: 16 Uses, and the One That Had to Be Inline

Centering, drawers and hover swaps across 8 components — plus the containing-block defect a no-op translate-x-0 shipped, and why a computed value can't be a class.

Read more