Search Bar

Text search input with icon and clear functionality. Install Search Bar from the VLLNT UI registry with the shadcn CLI.

Report a bug

Text search input with icon and clear functionality. Part of the form family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

Add Search Bar to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

pnpm dlx shadcn@latest add https://ui.vllnt.com/r/search-bar.json

Source

"use client"; import { Suspense, useEffect, useEffectEvent, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useDebounce } from "../../lib/use-debounce"; import { Button } from "../button/button"; import { Input } from "../input/input"; type SearchBarProps = { className?: string; onSearch?: (query: string) => void; placeholder?: string; }; export function SearchBar(props: SearchBarProps) { // useSearchParams suspends during SSR — Suspense boundary keeps the // surrounding tree streamable. See react-doctor rule // nextjs-no-use-search-params-without-suspense + Next.js docs. return ( <Suspense fallback={<SearchBarFallback {...props} />}> <SearchBarInner {...props} /> </Suspense> ); } function SearchBarFallback({ className, placeholder = "Search posts...", }: SearchBarProps) { return ( <form className={`flex gap-2 ${className}`}> <Input aria-label={placeholder} className="flex-1" disabled placeholder={placeholder} type="text" value="" /> <Button disabled type="submit" variant="outline"> Search </Button> </form> ); } function SearchBarInner({ className, onSearch, placeholder = "Search posts...", }: SearchBarProps) { const router = useRouter(); const searchParameters = useSearchParams(); const initialQuery = searchParameters.get("search") ?? ""; const [query, setQuery] = useState(initialQuery); const debouncedQuery = useDebounce(query, 300); const isInitialMount = useRef(true); const isUserTyping = useRef(false); const typingTimeoutReference = useRef<NodeJS.Timeout | undefined>(undefined); const lastSetSearchParameterReference = useRef<string>(""); const lastDebouncedQueryReference = useRef<string>(""); // Notify parent without making `onSearch` a reactive effect dependency: // an Effect Event always sees the latest prop but never re-triggers the // effect, so a parent passing a fresh callback can't drive an update loop. const emitSearch = useEffectEvent((nextQuery: string) => { onSearch?.(nextQuery); }); // Sync query with URL search params (e.g., on browser back/forward) // Sync when user is not actively typing and URL changed externally useEffect(() => { const searchParameter = searchParameters.get("search") ?? ""; // Skip if this is the search param we set ourselves if (searchParameter === lastSetSearchParameterReference.current) { return; } // Sync if user is not actively typing and values differ if (!isUserTyping.current && query !== searchParameter) { requestAnimationFrame(() => { setQuery(searchParameter); lastDebouncedQueryReference.current = searchParameter; }); } }, [searchParameters, query]); // Include query to properly sync state // Update URL when debounced query changes useEffect(() => { // Skip initial mount to avoid unnecessary URL update if (isInitialMount.current) { isInitialMount.current = false; const initialTrimmed = debouncedQuery.trim(); lastDebouncedQueryReference.current = initialTrimmed; lastSetSearchParameterReference.current = initialTrimmed; return; } const trimmedQuery = debouncedQuery.trim(); // Skip if this is the same value we already processed if (trimmedQuery === lastDebouncedQueryReference.current) { return; } lastDebouncedQueryReference.current = trimmedQuery; if (onSearch) { emitSearch(trimmedQuery); return; } // Check current URL to avoid unnecessary updates const currentUrlParameter = searchParameters.get("search") ?? ""; // Skip if URL already matches the debounced query if (trimmedQuery === currentUrlParameter) { lastSetSearchParameterReference.current = trimmedQuery; return; } const parameters = new URLSearchParams(searchParameters); if (trimmedQuery) { parameters.set("search", trimmedQuery); } else { parameters.delete("search"); } const newUrl = parameters.toString(); lastSetSearchParameterReference.current = trimmedQuery; // next/navigation router.replace is the canonical client-side // navigation primitive in Next App Router. The react-doctor // nextjs-no-client-side-redirect rule targets window.location // hard redirects, not Next router.replace — but ESLint doesn't // know that rule, so we don't add an eslint-disable for it. router.replace(`?${newUrl}`); }, [debouncedQuery, router, onSearch, searchParameters]); // Cleanup timeout on unmount useEffect(() => { return () => { if (typingTimeoutReference.current) { clearTimeout(typingTimeoutReference.current); } }; }, []); const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { isUserTyping.current = true; setQuery(event.target.value); // Clear existing timeout if (typingTimeoutReference.current) { clearTimeout(typingTimeoutReference.current); } // Reset typing flag after debounce delay + buffer typingTimeoutReference.current = setTimeout(() => { isUserTyping.current = false; }, 350); }; const handleSubmit = (event: React.SyntheticEvent) => { event.preventDefault(); isUserTyping.current = false; // Clear typing timeout if (typingTimeoutReference.current) { clearTimeout(typingTimeoutReference.current); } const trimmedQuery = query.trim(); if (onSearch) { onSearch(trimmedQuery); } else { const parameters = new URLSearchParams(searchParameters); if (trimmedQuery) { parameters.set("search", trimmedQuery); } else { parameters.delete("search"); } const newUrl = parameters.toString(); lastSetSearchParameterReference.current = trimmedQuery; router.replace(`?${newUrl}`); } }; return ( <form className={`flex gap-2 ${className}`} onSubmit={handleSubmit}> <Input aria-label={placeholder} className="flex-1" onChange={handleInputChange} placeholder={placeholder} type="text" value={query} /> <Button type="submit" variant="outline"> Search </Button> </form> ); }

Stories

Explore every variant and state in the interactive Storybook:

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Dependencies

  • @vllnt/ui@^0.3.0