Search Input

A debounced search input with optional keyboard shortcut and hotkey badge.

npx shadcn@latest add https://shuip.plvo.dev/r/search-input.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/search-input.json
bun x shadcn@latest add https://shuip.plvo.dev/r/search-input.json
'use client';
import { Search } from 'lucide-react';
import * as React from 'react';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable;
}
export interface SearchInputProps extends Omit<React.ComponentProps<'input'>, 'onChange'> {
onChange: (value: string) => void;
debounceMs?: number;
hotkey?: string | false;
hotkeyTooltip?: string;
}
export function SearchInput({
value,
onChange,
placeholder = 'Search…',
debounceMs = 250,
hotkey = '/',
hotkeyTooltip,
autoFocus,
className,
...props
}: SearchInputProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const [text, setText] = React.useState(typeof value === 'string' ? value : '');
const [prevValue, setPrevValue] = React.useState(value);
const [focused, setFocused] = React.useState(false);
const [hintOpen, setHintOpen] = React.useState(false);
if (prevValue !== value) {
setPrevValue(value);
if (typeof value === 'string') setText(value);
}
const resolvedTooltip = hotkeyTooltip ?? (hotkey ? `Press ${hotkey} to search` : undefined);
React.useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
React.useEffect(() => {
if (!hotkey) return;
function onKey(e: KeyboardEvent) {
if (e.key !== hotkey || e.metaKey || e.ctrlKey || e.altKey) return;
if (isEditableTarget(e.target)) return;
e.preventDefault();
inputRef.current?.focus();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [hotkey]);
function handleChange(next: string) {
setText(next);
if (timer.current) clearTimeout(timer.current);
if (debounceMs <= 0) {
onChange(next);
return;
}
timer.current = setTimeout(() => onChange(next), debounceMs);
}
const showHotkeyBadge = Boolean(hotkey) && !focused && text.length === 0;
return (
<div className={cn('relative', className)}>
<Search aria-hidden className='absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
<Input
{...props}
ref={inputRef}
type='search'
placeholder={placeholder}
value={text}
onChange={(e) => handleChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
autoFocus={autoFocus}
className={cn('h-9 pl-9', showHotkeyBadge && 'pr-9')}
/>
{showHotkeyBadge && (
<Popover open={hintOpen} onOpenChange={setHintOpen}>
<PopoverTrigger
type='button'
aria-label={resolvedTooltip}
onClick={() => inputRef.current?.focus()}
onMouseEnter={() => setHintOpen(true)}
onMouseLeave={() => setHintOpen(false)}
className='absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-xs font-medium text-muted-foreground transition-colors hover:text-foreground'
>
{hotkey}
</PopoverTrigger>
<PopoverContent
side='top'
align='end'
sideOffset={8}
onOpenAutoFocus={(e) => e.preventDefault()}
className='w-auto p-2 text-xs text-muted-foreground'
>
{resolvedTooltip}
</PopoverContent>
</Popover>
)}
</div>
);
}
Loading...

A controlled search input with built-in debounce. Press a configurable hotkey (default /) to focus it from anywhere on the page. When the field is empty and unfocused, a badge shows the active hotkey; hovering the badge reveals a tooltip.

Examples

Default

Loading...
'use client';
import { useState } from 'react';
import { SearchInput } from '@/components/ui/shuip/search-input';
export default function SearchInputExample() {
const [query, setQuery] = useState('');
return (
<div className='flex w-full max-w-sm flex-col gap-2'>
<SearchInput value={query} onChange={setQuery} placeholder='Search…' />
{query && <p className='text-sm text-muted-foreground'>Query: {query}</p>}
</div>
);
}

No Hotkey

Loading...
'use client';
import { useState } from 'react';
import { SearchInput } from '@/components/ui/shuip/search-input';
export default function SearchInputNoHotkeyExample() {
const [query, setQuery] = useState('');
return (
<SearchInput
value={query}
onChange={setQuery}
hotkey={false}
placeholder='Filter items…'
className='w-full max-w-sm'
/>
);
}

With Hotkey

Loading...
'use client';
import { useState } from 'react';
import { SearchInput } from '@/components/ui/shuip/search-input';
export default function SearchInputWithHotkeyExample() {
const [query, setQuery] = useState('');
return (
<SearchInput
value={query}
onChange={setQuery}
hotkey='k'
hotkeyTooltip='Press K to search'
placeholder='Search…'
className='w-full max-w-sm'
/>
);
}

Props

Prop

Type

On this page