Preview
- AVL-2841 (Avocado, large)
- MGO-1107 (Mango, organic)
- BNN-0921 (Banana, fair-trade)
Installation
Install the runtime packages:
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add search
Wire the tokens into your Tailwind v4 stylesheet:
/* app/globals.css */ @import 'tailwindcss'; @import '@oshon-ai/tokens/css'; @import '@oshon-ai/tokens/tailwind';
New here? Walk through the full setup — prereqs, theming, your first render.
Usage
Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.
'use client';
import { Search } from '@oshon-ai/components';
export default function Example() {
return <Search />;
}Default
<Search size="m" placeholder="Search" aria-label="Demo search" />
Size matrix
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
<div key="xs" data-story-size="xs">
<Search size="xs" placeholder="Search" aria-label="Demo search" />
</div>
<div key="s" data-story-size="s">
<Search size="s" placeholder="Search" aria-label="Demo search" />
</div>
<div key="m" data-story-size="m">
<Search size="m" placeholder="Search" aria-label="Demo search" />
</div>
<div key="l" data-story-size="l">
<Search size="l" placeholder="Search" aria-label="Demo search" />
</div>
<div key="mobile" data-story-size="mobile">
<Search size="mobile" placeholder="Search" aria-label="Demo search" />
</div>
</div>API
Every prop is documented here directly from the component's TypeScript interface. Inherited DOM attributes (aria-*, onClick, style, etc.) work as usual but are omitted from this table.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | — | aria-label for the field — falls back to "Search". |
className | string | — | Additional classes merged after the root defaults. |
defaultOpen | boolean | — | Uncontrolled initial open state. |
defaultValue | string | — | Uncontrolled initial value. |
disabled | boolean | — | Disabled state — disables pointer events + dims text. |
helperText | ReactNode | — | Hero-only: helper line under the field. |
inputProps | Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "defaultValue" | "size" | "value" | "placeholder" | "disabled"> | — | Forwarded to the inner `<input>` so consumers can pass a name, autocomplete hint, etc. without forking the component. |
onOpenChange | ((open: boolean) => void) | — | Called when the dropdown opens or closes. |
onSegmentedClick | (() => void) | — | Hero-only: fires when the segmented chunk is clicked. |
onSelectSuggestion | ((suggestion: SearchSuggestion) => void) | — | Fires when a suggestion row is clicked. |
onSubmit | ((value: string) => void) | — | Hero-only: fires when the trailing CTA (search arrow) is clicked OR the user presses Enter while the input has focus. Field/component also fire on Enter, so this prop is shared. |
onValueChange | ((value: string) => void) | — | Called on every keystroke with the new value. |
open | boolean | — | Controlled open (active/dropdown) state. |
placeholder | string | — | Placeholder text. Defaults to "Search" (or "Search PI, MI, or SI..." for hero). |
segmentedLabel | ReactNode | — | Hero-only: leading "Segmented" group label (Figma renders "All"). Pass undefined to omit the segmented chunk. |
size | enum | m | Visual size. Default `'m'`. |
slotClassName | SearchSlotClassNames | — | Per-slot className overrides. |
suggestions | readonly SearchSuggestion[] | — | Dropdown row data. When non-empty + `open` (or focus), the panel renders. |
type | enum | field | Visual surface. Default `'field'`. |
value | string | — | Controlled value. |
Styling
Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.
Passing Tailwind classes
Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.
<Search className="ring-2 ring-offset-2 ring-blue-500" />
Data attributes
Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
--oshon-color-primary-700: var(--my-brand-color);
}Interactive states
Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.
:hover/hover:*— pointer hover:focus-visible/focus-visible:*— keyboard focus:active/active:*— pressed:disabled/disabled:*— set via thedisabledprop
Anatomy
The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.
fieldThe interactive shell — border, background, focus ring, and inner gap. Carries `data-oshon-component="search"`, `data-oshon-type`, `data-oshon-size`, `data-oshon-open`, `data-oshon-disabled`, and `data-oshon-filled` for QA hooks.
iconLeading magnifying-glass glyph. Inline SVG sized per the size token (12/14/16). Override via `slotClassName.icon` to swap the color or replace the glyph entirely.
inputThe native `<input type="search">` element. Forwards all caller `inputProps` (autocomplete, name, etc.) but consumes `value`/`defaultValue`/`onChange`/`disabled`/`placeholder`/`size` so the controlled contract stays consistent.
clearTrailing CloseAlt glyph that appears once the field has a value. `<button aria-label="Clear search">` — `mousedown.preventDefault` keeps focus on the input across the click.
dropdownSuggestion panel rendered when `type="field"` + open + `suggestions.length > 0`. Frosted-glass chrome (rgba(223,226,226,0.4) + 16px backdrop-blur), 1px gray-300 border, 8px radius, drop-shadow + inner highlight. `role="listbox"` with `role="option"` children.
segmentedHero-only leading "All" pivot button. Wraps the `segmentedLabel` content + a chevron-down glyph. Separated from the input by a 1px right border. Click fires `onSegmentedClick`.
ctaHero-only trailing 40×40 white CTA button with the arrow-search glyph. Click (or Enter on the input) fires `onSubmit(value)`.
helperHero-only centered helper line (14/18 neutral-700) rendered below the pill when `helperText` is provided.
Keyboard
Field & Component: input is `<input type="search">` (implicit `role="searchbox"`); when `suggestions` is non-empty it surfaces `aria-autocomplete="list"` and (while open) `aria-controls` pointing at the dropdown listbox id. The dropdown is `role="listbox"`; rows are `role="option"`. Enter fires `onSubmit(value)`. Escape clears the value (first press) then closes the dropdown (second press). Trailing clear button is `<button aria-label="Clear search">` with `mousedown.preventDefault` so input focus is preserved across the click. Hero adds a `<button>` segmented chunk + a `<button aria-label="…submit">` CTA. Disabled cuts pointer events + applies opacity 0.6 + the native `disabled` attribute. Focus ring is `:focus-visible` only.
Accessibility
Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.
- WCAG level
- 2.2 AA
- Screen readers tested
- VoiceOver (macOS), NVDA (Windows)
- Last axe audit
- 2026-04-28
Do / Don't
✓ Do
<Search
suggestions={[
{ id: '1', label: 'Apple' },
{ id: '2', label: 'Banana' },
]}
onSelectSuggestion={(s) => console.log(s.id)}
/><Search type="component" placeholder="Search records" />
<Search
type="hero"
segmentedLabel="All"
onSegmentedClick={() => {}}
helperText="Search across all product data, queries, and management tools"
onSubmit={(q) => navigate(`/search?q=${q}`)}
/><Search
size="mobile"
value={query}
onValueChange={setQuery}
placeholder="Search…"
/>✗ Don't
<Search slotClassName={{ field: "border-[#aaaaaa]" }} />Bypassing the neutral-* tokens defeats white-label theming (principle #6). Override the slot only when you need to attach behavior (e.g. a hover ring); leave color decisions to the tokens.
<Search value={query} defaultValue="seed" onValueChange={setQuery} />Controlled and uncontrolled value sources fight for ownership and produce an unstable initial render. Pick one — `value` + `onValueChange` for controlled, `defaultValue` alone for uncontrolled.
<Search open suggestions={[]} />The dropdown panel only renders when `suggestions.length > 0`, regardless of the `open` prop. Passing `open` with an empty array signals an API misuse — either omit `suggestions` (no autocomplete) or supply rows.
<div style={{ background: "white" }}><Search type="component" /></div>Component density relies on `mix-blend-multiply` to tint a colored hosting surface; on a flat white background it reads identical to the field type without any of the bg/border affordances. Use `type="field"` on white cards; reserve `type="component"` for tinted toolbars and page headers.
Design rationale
Search is one of the highest-volume primitives in any product surface, so the design system needs a single component that can land on a form (field), a toolbar (component), or a dashboard (hero) without forking. The Figma source ships three named surfaces; we collapse them into a single `type` prop so the controlled-input + dropdown + clear-button + submit contract is shared. Sizes follow the standard five-axis convention: xs maps to the Component-density 24px / 10pt density (Figma's smallest published surface); s/m map to Field/Desktop 24px / 12pt; l/mobile map to Field/Mobile 34px / 14pt. Hero ignores the size axis for height (its chrome is fixed by Figma at 40×40 CTA + 16/8 inner padding) and only respects size for the helper text. The active-state dropdown intentionally reuses Popup chrome (frosted gray-300 wash + 16px backdrop-blur + drop-shadow-with-inner-highlight) so the same visual language carries across every floating surface in the system. Escape behaviour is two-stage — first press clears the typed value, second press closes the dropdown — matching the Mac OS convention users expect from Safari and Spotlight. The clear button uses `mousedown.preventDefault()` so the input focus returns to the input after the click; without that the click would blur the input and close the dropdown before the clear handler fires.