Search Bar
Text search input with icon and clear functionality. Install Search Bar from the VLLNT UI registry with the shadcn CLI.
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.jsonSource
"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.