Search Input
Search Input for a useful, keyboard-friendly product interface.
Make it yours.
Install the editable source and its dependencies into your React + Tailwind project.
Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/search-input.jsonUse it
Scroll horizontally for long lines
"use client";
import { useState } from "react";
import { SearchInput } from "@/components/jez-ui/ui/search-input";
import { Folder, ArrowUpRight } from "lucide-react";
export default function Example() {
const [searchTerm, setSearchTerm] = useState("");
const [notice, setNotice] = useState("");
return (
<>
<div className="w-full max-w-sm">
<div className="mb-7">
<h3 className="text-2xl tracking-tight">Find the thing.</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Projects, notes, and the details you nearly forgot.
</p>
</div>
<div>
<SearchInput
value={searchTerm}
onValueChange={setSearchTerm}
placeholder="Search projects…"
/>
<div className="mt-3 overflow-hidden rounded-lg border border-border">
{["Website refresh", "Brand guidelines", "Component library"]
.filter((t) => t.toLowerCase().includes(searchTerm.toLowerCase()))
.map((t) => (
<button
key={t}
onClick={() => setNotice(t + " opened.")}
className="flex w-full items-center gap-3 border-b border-border/50 px-3 py-3 text-left text-sm last:border-0 hover:bg-muted"
>
<Folder
size={15}
className="text-muted-foreground"
/>
{t}
<ArrowUpRight
size={13}
className="ml-auto text-muted-foreground"
/>
</button>
))}
{!["Website refresh", "Brand guidelines", "Component library"].some(
(t) => t.toLowerCase().includes(searchTerm.toLowerCase()),
) && (
<p className="p-5 text-sm text-muted-foreground">
No projects found.
</p>
)}
</div>
</div>
</div>
<p role="status">{notice}</p>
</>
);
}
Props & types
Scroll horizontally for long lines
Omit<
React.ComponentProps<typeof Input>,
"value" | "defaultValue" | "onChange"
> & {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
loading?: boolean;
inputClassName?: string;
clearLabel?: string;
}Source
View 4 source files
registry/ui/search-input.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Search, X, LoaderCircle } from "lucide-react";
import { Input } from "./input";
import { useControllable } from "./use-controllable";
import { cn } from "./utils";
export function SearchInput({
value,
defaultValue = "",
onValueChange,
className,
inputClassName,
ref: forwardedRef,
clearLabel = "Clear search",
loading = false,
...props
}: Omit<
React.ComponentProps<typeof Input>,
"value" | "defaultValue" | "onChange"
> & {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
loading?: boolean;
inputClassName?: string;
clearLabel?: string;
}) {
const [query, setQuery] = useControllable(value, defaultValue, onValueChange);
const ref = React.useRef<HTMLInputElement>(null);
React.useImperativeHandle(forwardedRef, () => ref.current!, []);
return (
<div role="search" className={cn("group relative w-full", className)}>
{loading ? (
<LoaderCircle
size={16}
className="absolute left-3 top-3 animate-spin text-primary"
/>
) : (
<Search
size={16}
className="pointer-events-none absolute left-3 top-3 text-muted-foreground transition-colors group-focus-within:text-primary"
/>
)}
<Input
ref={ref}
type="search"
aria-label="Search"
placeholder="Search…"
{...props}
value={query}
onChange={(e) => setQuery(e.target.value)}
className={cn(
"px-10 [&::-webkit-search-cancel-button]:appearance-none",
inputClassName,
)}
/>
{query && (
<button
type="button"
disabled={props.disabled}
aria-label={clearLabel}
onClick={() => {
setQuery("");
ref.current?.focus();
}}
className="absolute right-1 top-1 grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X size={14} />
</button>
)}
</div>
);
}
registry/ui/input.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
export function Input({ className, ...props }: React.ComponentProps<"input">) {
return (
<input
className={cn(
"block h-10 w-full min-w-0 rounded-lg border border-border bg-background px-3 text-sm leading-normal shadow-[0_1px_2px_#00000004] transition-[border-color,box-shadow] placeholder:text-muted-foreground/75 hover:border-foreground/25 focus:border-primary focus:outline-none focus:ring-3 focus:ring-primary/10 aria-invalid:border-danger aria-invalid:ring-danger/10 disabled:cursor-not-allowed disabled:bg-muted/50 disabled:opacity-50",
className,
)}
{...props}
/>
);
}
registry/ui/utils.ts
Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
return twMerge(clsx(values));
}
registry/ui/use-controllable.ts
Scroll horizontally for long lines
"use client";
import { useRef, useState, type SetStateAction } from "react";
/** Controlled or instance-local state. Functional updates also work before a rerender. */
export function useControllable<T>(
value: T | undefined,
initial: T,
onChange?: (value: T) => void,
) {
const [local, setLocal] = useState(initial);
const current = useRef(value === undefined ? local : value);
current.current = value === undefined ? local : value;
return [
current.current,
(action: SetStateAction<T>) => {
const next =
typeof action === "function"
? (action as (previous: T) => T)(current.current)
: action;
current.current = next;
if (value === undefined) setLocal(next);
onChange?.(next);
},
] as const;
}
Dependencies
lucide-react@0.577.0 · clsx@2.1.1 · tailwind-merge@3.5.0
Accessibility & behaviour
Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.
Read the accessibility and performance guide →