- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Type @ to narrow the list to people you work with.
That filter is incomplete.
Installation
$ pnpm dlx shadcn@latest add @cobalt/search-field
Usage
import { SearchField } from "@/components/ui/search-field";
<SearchField
label="Search teammates"
placeholder="Search by name or email"
onClear={() => resetFilters()}
/>;Escape clears the field and Enter calls onSubmit when provided. The clear button stays out of the tab order because Escape does its job from the keyboard.
variant="muted" recesses the field into the surface, with the same fill as InputGroup's muted variant, for toolbars on a card. The default is variant="default".
Source
"use client";
import { Input as InputPrimitive } from "@base-ui/react/input";
import { RiSearchLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
type IconComponent = React.ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
export interface SearchFieldProps extends Omit<
React.ComponentProps<"input">,
| "children"
| "className"
| "defaultValue"
| "onChange"
| "onSubmit"
| "ref"
| "size"
| "value"
> {
label?: React.ReactNode;
hint?: React.ReactNode;
/** 36px (`default`) or 32px (`sm`). */
size?: "default" | "sm";
/** `pill` (default) or `rounded`, Input's corners. */
shape?: "pill" | "rounded";
/** `default` (page surface) or `muted` (recessed well, as InputGroup's muted). */
variant?: "default" | "muted";
/** Show Spinner's light in place of the icon while results load. */
isLoading?: boolean;
/** Replaces the search icon. */
icon?: IconComponent;
/** Accessible name of the clear button. Default "Clear search". */
clearLabel?: string;
className?: string;
fieldClassName?: string;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
onClear?: () => void;
onSubmit?: (value: string) => void;
}
function SearchField({
label,
hint,
placeholder,
size = "default",
shape = "pill",
variant = "default",
isLoading = false,
icon: Icon = RiSearchLine,
clearLabel = "Clear search",
className,
fieldClassName,
value: controlledValue,
defaultValue = "",
onChange,
onClear,
onSubmit,
disabled = false,
readOnly = false,
required = false,
"aria-label": ariaLabel,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
onKeyDown,
...inputProps
}: SearchFieldProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const generatedId = React.useId();
const inputId = inputProps.id ?? generatedId;
const hintId = React.useId();
const [internalValue, setInternalValue] = React.useState(defaultValue);
const isControlled = controlledValue !== undefined;
const inputValue = isControlled ? controlledValue : internalValue;
const isEmpty = inputValue === "";
const isInvalid = ariaInvalid === true || ariaInvalid === "true";
const change = (next: string) => {
if (!isControlled) {
setInternalValue(next);
}
onChange?.(next);
};
const clear = () => {
change("");
onClear?.();
inputRef.current?.focus();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) {
return;
}
if (event.key === "Enter" && onSubmit) {
event.preventDefault();
onSubmit(inputValue);
return;
}
if (event.key === "Escape" && !disabled && !readOnly) {
if (inputValue === "" && (inputRef.current?.value ?? "") === "") {
return;
}
event.preventDefault();
change("");
onClear?.();
}
};
return (
<div
data-slot="search-field"
data-disabled={disabled ? "true" : undefined}
data-invalid={isInvalid ? "true" : undefined}
className={cn("group flex w-full flex-col items-start gap-1", className)}
>
{label ? (
<Label
data-slot="search-field-label"
htmlFor={inputId}
className="cursor-default gap-0.5 leading-5 text-foreground"
>
{label}
{required && (
<span
aria-hidden="true"
className="text-[color:var(--field-invalid,var(--destructive))]"
>
*
</span>
)}
</Label>
) : null}
<div
data-slot="search-field-control"
data-shape={shape}
data-variant={variant}
data-disabled={disabled ? "true" : undefined}
data-invalid={isInvalid ? "true" : undefined}
className={cn(
"relative flex w-full items-center ring-2 ring-transparent ring-inset transition-[background-color,box-shadow,color] duration-150 ease-[ease] outline-none motion-reduce:transition-none",
variant === "muted"
? "rounded-full border-transparent bg-[var(--surface-sunken,var(--muted))] text-[color:var(--foreground-subtle,var(--muted-foreground))]"
: "rounded-[10px] bg-[var(--surface-strong,var(--muted))] text-[color:var(--command-muted-foreground,var(--foreground-subtle,var(--muted-foreground)))] hover:not-focus-within:not-has-[input:disabled]:not-has-[input[aria-invalid=true]]:ring-[var(--border-hover,var(--border))]",
// cobalt-allow-focus-within -- text input
"focus-within:not-has-[input:disabled]:not-has-[input[aria-invalid=true]]:ring-[var(--foreground-subtle,var(--ring))]",
"data-[disabled=true]:not-data-[invalid=true]:bg-[var(--input-disabled-surface,var(--muted))] data-[disabled=true]:not-data-[invalid=true]:text-[color:var(--search-field-disabled-icon,var(--input-disabled-foreground,var(--muted-foreground)))]",
"data-[invalid=true]:bg-[var(--input-invalid-surface,color-mix(in_srgb,var(--destructive)_15%,transparent))] data-[invalid=true]:text-destructive",
size === "default" ? "h-9 pe-2 ps-1 py-2" : "h-8 pe-1.5 ps-1 py-2",
shape === "pill" && "rounded-full",
fieldClassName
)}
>
<div className="flex w-full min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-0.5">
{/* The icon and the light share one 20px box and cross-fade. */}
<span aria-hidden="true" className="relative ms-1 size-5 shrink-0">
<Icon
className={cn(
"absolute inset-0 size-5 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none",
variant === "muted" &&
!isInvalid &&
!disabled &&
"text-[color:var(--icon-foreground,var(--muted-foreground))]",
isLoading && "scale-75 opacity-0"
)}
/>
<span
className={cn(
"absolute inset-0 flex items-center justify-center transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none",
// Hidden, the light stays mounted for the fade but stops turning.
!isLoading &&
"scale-75 opacity-0 [&_*]:[animation-play-state:paused]"
)}
>
<Spinner aria-hidden={true} />
</span>
</span>
<InputPrimitive
{...inputProps}
ref={inputRef}
id={inputId}
type={inputProps.type ?? "search"}
data-slot="search-field-input"
value={inputValue}
disabled={disabled}
readOnly={readOnly}
required={required}
aria-label={
ariaLabel ??
(!label && typeof placeholder === "string"
? placeholder
: undefined)
}
aria-invalid={ariaInvalid}
aria-describedby={
[ariaDescribedBy, hint ? hintId : undefined]
.filter(Boolean)
.join(" ") || undefined
}
placeholder={placeholder}
onChange={(event) => change(event.target.value)}
onKeyDown={handleKeyDown}
className={cn(
"m-0 min-w-0 flex-1 border-0 bg-transparent p-0 ps-1 text-start font-sans text-sm leading-5 font-normal text-foreground outline-none focus:placeholder:text-foreground disabled:cursor-not-allowed disabled:text-[color:var(--search-field-disabled-text,var(--input-disabled-foreground,var(--muted-foreground)))] disabled:placeholder:text-[color:var(--search-field-disabled-text,var(--input-disabled-foreground,var(--muted-foreground)))] aria-invalid:placeholder:text-[color:var(--input-invalid-placeholder,var(--destructive))] [&::-webkit-search-cancel-button]:hidden",
variant === "muted"
? "placeholder:text-[color:var(--foreground-subtle,var(--muted-foreground))]"
: "placeholder:text-[color:var(--command-muted-foreground,var(--foreground-subtle,var(--muted-foreground)))]"
)}
/>
</div>
<button
type="button"
data-slot="search-field-clear"
aria-label={clearLabel}
aria-hidden={isEmpty || undefined}
tabIndex={-1}
disabled={disabled}
onMouseDown={(event) => event.preventDefault()}
onClick={clear}
className={cn(
"me-0.5 inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-full bg-[var(--trace-guide,color-mix(in_srgb,var(--foreground)_12%,transparent))] text-muted-foreground transition-[opacity,scale,color] duration-150 ease-out select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none motion-reduce:transition-none",
isEmpty || disabled
? "pointer-events-none scale-50 opacity-0"
: "scale-100 opacity-100"
)}
>
<svg
width={6.8}
height={6.8}
viewBox="0 0 6.8 6.8"
fill="none"
aria-hidden="true"
>
<path
d="M0.57 0.57L6.23 6.23"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
/>
<path
d="M6.23 0.57L0.57 6.23"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
{hint ? (
<p
id={hintId}
data-slot="search-field-hint"
className={cn(
"pt-px text-[12px] leading-4 font-medium tracking-[0.15px]",
isInvalid
? "text-[color:var(--field-invalid,var(--destructive))]"
: "text-muted-foreground"
)}
>
{hint}
</p>
) : null}
</div>
);
}
export { SearchField }; On this page