Skip to content
ssntl/ui

Search field

A 48px search input with an icon and a clear button, the height of the button beside it.

Installation

$ pnpm dlx shadcn@latest add @ssntl/search-field

Usage

tsx
import { SearchField } from "@/components/ui/search-field"

<form role="search" className="flex gap-2">
  <SearchField name="q" aria-label="Search orders" placeholder="Number, client or product" className="flex-1" />
  <Button type="submit" variant="secondary">Search</Button>
</form>
  • 48px, the height of Button and Input, so a field and its button line up top, bottom and text.
  • type="search" inside a role="search" form; Enter submits.
  • The clear button appears only with text, keeps focus in the field and has its own name (clearLabel).

Code

components/ui/search-field.tsx
"use client"

import * as React from "react"
import { Search, X } from "lucide-react"

import { cn } from "@/lib/utils"

// A 48px search input, the height of Button and Input, so a field and its
// button line up when they sit side by side. The icon says what it is; the
// clear button appears once there is something to clear and keeps focus in
// the field. Enter submits the surrounding form.
function SearchField({
  value,
  defaultValue,
  onChange,
  onClear,
  clearLabel = "Clear search",
  className,
  ...props
}: Omit<React.ComponentProps<"input">, "type" | "value" | "defaultValue"> & {
  value?: string
  defaultValue?: string
  /** Called after the clear button empties the field. */
  onClear?: () => void
  /** The clear button's accessible name. */
  clearLabel?: string
}) {
  const inputRef = React.useRef<HTMLInputElement>(null)
  const [inner, setInner] = React.useState(defaultValue ?? "")
  const current = value ?? inner

  return (
    <div
      data-slot="search-field"
      className={cn(
        "flex h-12 min-w-0 items-center gap-3 rounded-sm border border-input bg-card ps-4 pe-1",
        "transition-[border-color] duration-(--duration-fast) ease-out hover:border-muted-foreground",
        "focus-within:border-foreground has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-ring",
        className
      )}
    >
      <Search
        aria-hidden
        strokeWidth={1.6}
        className="size-5 shrink-0 text-muted-foreground"
      />
      <input
        ref={inputRef}
        type="search"
        value={current}
        onChange={(event) => {
          setInner(event.target.value)
          onChange?.(event)
        }}
        className="h-full min-w-0 flex-1 bg-transparent text-body outline-none placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none"
        {...props}
      />
      {current && (
        <button
          type="button"
          aria-label={clearLabel}
          onClick={() => {
            setInner("")
            onClear?.()
            inputRef.current?.focus()
          }}
          className="grid size-10 shrink-0 cursor-pointer place-items-center rounded-xs text-muted-foreground transition-[background-color,color] duration-(--duration-fast) ease-out hover:bg-secondary hover:text-foreground"
        >
          <X aria-hidden strokeWidth={1.6} className="size-4" />
        </button>
      )}
    </div>
  )
}

export { SearchField }