Combobox
Single- or multi-value ARIA combobox with filtering.
- Browsers
- Chrome 114+Firefox 125+Safari 17+
- Tier
- 2
- Bundle compressed
- 5.18 kB
- Forced layout budget
- 0 ms
- Axe budget
- 0
- SSR compatible
- Yes
- Published contract
- Test harness
Playground
Combobox
Default
Apple
Banana
Cherry
Date
Multiple
ReactUI
React
UI
TypeScript
Accessibility
Design systems
Open
Apple
Banana
Cherry
Date
Large
"use client";
import { Combobox } from "@gunkin/uiify";
const skills = [
{ label: "React", value: "React" },
{ label: "UI", value: "UI" },
{ label: "TypeScript", value: "TypeScript" },
{ label: "Accessibility", value: "Accessibility" },
];
export default function Example() {
return (
<div className="">
<Combobox.Root multiple defaultValue={["React", "UI"]} items={skills}>
<Combobox.Control className="">
<Combobox.Tags className="" />
<Combobox.Input className="" aria-label="Add tag" placeholder="Add tag" />
</Combobox.Control>
<Combobox.Content className="">
{(items) =>
items.length === 0 ? (
<Combobox.Empty>Nothing matched. Try another search.</Combobox.Empty>
) : (
items.map((item) => <Combobox.Item className="" item={item} key={item.value} />)
)
}
</Combobox.Content>
</Combobox.Root>
</div>
);
}/* Combobox — text input + listbox surface + option rows. The active option is
tracked via aria-activedescendant, surfaced here as [data-highlighted]. */
@layer components {
[data-uiify-combobox] {
position: relative;
inline-size: 100%;
max-inline-size: 18rem;
}
[data-uiify-combobox] [data-part="control"] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
inline-size: 100%;
min-block-size: 2.5rem;
padding-block: var(--space-sm);
padding-inline: var(--space-md);
border: var(--border-width) solid var(--input);
border-radius: var(--radius-md);
corner-shape: squircle;
background-color: var(--card);
color: var(--foreground);
transition: var(--transition-colors);
}
[data-uiify-combobox] [data-part="control"]:focus-within {
border-color: var(--primary);
}
[data-uiify-combobox] [data-part="control"][data-disabled],
[data-uiify-combobox] [data-part="control"]:has([data-part="input"]:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
[data-uiify-combobox] [data-part="control"] [data-part="tags"] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
}
[data-uiify-combobox] [data-part="control"] [data-part="tag"] {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding-block: var(--space-xs);
padding-inline: var(--space-sm);
border-radius: var(--radius-xs);
corner-shape: squircle;
background-color: var(--accent);
color: var(--accent-foreground);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
}
[data-uiify-combobox] [data-part="control"] [data-part="tag-remove"] {
display: inline-grid;
place-items: center;
inline-size: 1.25rem;
block-size: 1.25rem;
flex: 0 0 1.25rem;
padding: 0;
border: 0;
border-radius: var(--radius-xs);
corner-shape: squircle;
background: transparent;
color: inherit;
font: inherit;
line-height: 1;
cursor: pointer;
}
[data-uiify-combobox] [data-part="control"] [data-part="tag-remove"]:hover,
[data-uiify-combobox] [data-part="control"] [data-part="tag-remove"]:focus-visible {
background-color: var(--primary);
color: var(--primary-foreground);
}
[data-uiify-combobox] [data-part="input"] {
inline-size: 100%;
padding-block: var(--space-sm);
padding-inline: var(--space-md);
border: var(--border-width) solid var(--input);
border-radius: var(--radius-md);
corner-shape: squircle;
background-color: var(--card);
color: var(--foreground);
font-size: var(--uiify-control-text);
transition: var(--transition-colors);
}
[data-uiify-combobox] [data-part="input"]:focus-visible {
border-color: var(--primary);
}
[data-uiify-combobox] [data-part="control"] [data-part="input"] {
flex: 1 1 5rem;
min-inline-size: 5rem;
inline-size: auto;
padding-block: var(--space-xs);
padding-inline: 0;
border: 0;
border-radius: 0;
background: transparent;
outline: none;
box-shadow: none;
}
[data-uiify-combobox] [data-part="content"]:where(:not([popover]), :popover-open) {
display: flex;
}
[data-uiify-combobox] [data-part="content"] {
flex-direction: column;
min-inline-size: 12rem;
max-block-size: min(24rem, calc(100dvb - 2rem));
overflow-block: auto;
overscroll-behavior: contain;
padding: var(--space-xs);
border: var(--border-width) solid var(--border);
border-radius: var(--radius-lg);
corner-shape: squircle;
background-color: var(--card);
color: var(--foreground);
box-shadow: var(--shadow-popover);
transform-origin: var(--transform-origin, top);
}
[data-uiify-combobox] [data-part="content"][data-state="closed"] {
display: none;
}
[data-uiify-combobox] [data-part="item"] {
display: flex;
align-items: center;
inline-size: 100%;
padding-block: var(--space-sm);
padding-inline: var(--space-md);
border: 0;
border-radius: calc(var(--radius-lg) - var(--space-xs));
corner-shape: squircle;
background: transparent;
color: inherit;
font: inherit;
font-size: var(--text-sm);
line-height: var(--leading-normal);
text-align: start;
cursor: pointer;
user-select: none;
}
[data-uiify-combobox] [data-part="item"]:hover,
[data-uiify-combobox] [data-part="item"]:focus-visible,
[data-uiify-combobox] [data-part="item"][data-highlighted],
[data-uiify-combobox] [data-part="item"][aria-selected="true"],
[data-uiify-combobox] [data-part="item"][data-selected] {
background-color: var(--accent);
color: var(--accent-foreground);
outline: none;
}
[data-uiify-combobox] [data-part="item"][aria-disabled="true"],
[data-uiify-combobox] [data-part="item"][data-disabled] {
opacity: 0.5;
pointer-events: none;
}
[data-uiify-combobox] [data-part="empty"] {
padding: var(--space-sm) var(--space-md);
color: var(--muted-foreground);
font-size: var(--text-sm);
}
@media (prefers-reduced-motion: no-preference) {
[data-uiify-combobox] [data-part="content"] {
transition: var(--transition-presence);
}
[data-uiify-combobox] [data-part="content"][popover]:not(:popover-open) {
opacity: 0;
scale: var(--presence-scale);
}
@starting-style {
[data-uiify-combobox] [data-part="content"]:popover-open {
opacity: 0;
scale: var(--presence-scale);
}
}
}
}"use client";
import { Combobox } from "@gunkin/uiify";
const skills = [
{ label: "React", value: "React" },
{ label: "UI", value: "UI" },
{ label: "TypeScript", value: "TypeScript" },
{ label: "Accessibility", value: "Accessibility" },
];
export default function Example() {
return (
<div className="relative w-full max-w-72">
<Combobox.Root multiple defaultValue={["React", "UI"]} items={skills}>
<Combobox.Control
className="
flex flex-wrap items-center gap-xs w-full min-h-10 py-sm px-md border-[length:var(--border-width)]
border-solid border-input rounded-md [corner-shape:squircle] bg-card text-foreground
[transition:var(--transition-colors)] focus-within:border-primary data-[disabled]:opacity-50
data-[disabled]:cursor-not-allowed has-[[data-part=input]:disabled]:opacity-50
has-[[data-part=input]:disabled]:cursor-not-allowed
"
>
<Combobox.Tags
className="
flex flex-wrap items-center gap-xs [&>[data-part=tag]]:inline-flex [&>[data-part=tag]]:items-center
[&>[data-part=tag]]:gap-xs [&>[data-part=tag]]:py-xs [&>[data-part=tag]]:px-sm
[&>[data-part=tag]]:rounded-xs [&>[data-part=tag]]:[corner-shape:squircle] [&>[data-part=tag]]:bg-accent
[&>[data-part=tag]]:text-accent-foreground [&>[data-part=tag]]:text-xs [&>[data-part=tag]]:font-medium
[&_[data-part=tag-remove]]:inline-grid [&_[data-part=tag-remove]]:place-items-center
[&_[data-part=tag-remove]]:size-5 [&_[data-part=tag-remove]]:flex-none [&_[data-part=tag-remove]]:p-0
[&_[data-part=tag-remove]]:border-0 [&_[data-part=tag-remove]]:rounded-xs
[&_[data-part=tag-remove]]:bg-transparent [&_[data-part=tag-remove]]:text-inherit
[&_[data-part=tag-remove]]:[font:inherit] [&_[data-part=tag-remove]]:leading-none
[&_[data-part=tag-remove]]:cursor-pointer [&_[data-part=tag-remove]:hover]:bg-primary
[&_[data-part=tag-remove]:hover]:text-primary-foreground
[&_[data-part=tag-remove]:focus-visible]:bg-primary
[&_[data-part=tag-remove]:focus-visible]:text-primary-foreground
"
/>
<Combobox.Input
className="
w-full py-sm px-md border-[length:var(--border-width)] border-solid border-input rounded-md
[corner-shape:squircle] bg-card text-foreground [font-size:var(--uiify-control-text)]
[transition:var(--transition-colors)] focus-visible:border-primary [[data-part=control]_&]:flex-[1_1_5rem]
[[data-part=control]_&]:[min-inline-size:5rem] [[data-part=control]_&]:[inline-size:auto]
[[data-part=control]_&]:py-xs [[data-part=control]_&]:px-0 [[data-part=control]_&]:border-0
[[data-part=control]_&]:rounded-none [[data-part=control]_&]:bg-transparent
[[data-part=control]_&]:[outline:none] [[data-part=control]_&]:[box-shadow:none]
"
aria-label="Add tag"
placeholder="Add tag"
/>
</Combobox.Control>
<Combobox.Content
className="
[&[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)]
[&_[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)]
[&[popover]:popover-open:not([data-positioning=native])]:[margin:revert]
[&_[popover]:popover-open:not([data-positioning=native])]:[margin:revert] flex-col [min-inline-size:12rem]
[max-block-size:min(24rem,calc(100dvb_-_2rem))] overflow-y-auto overscroll-contain p-xs
border-[length:var(--border-width)] border-solid border-border rounded-lg [corner-shape:squircle] bg-card
text-foreground shadow-popover [transform-origin:var(--transform-origin,top)] data-[state=closed]:hidden
[&:where(:not([popover]),:popover-open)]:flex motion-safe:[transition:var(--transition-presence)]
motion-safe:data-[state=closed]:opacity-0 motion-safe:data-[state=closed]:[scale:var(--presence-scale)]
motion-safe:starting:[&:popover-open]:opacity-0
motion-safe:starting:[&:popover-open]:[scale:var(--presence-scale)]
"
>
{(items) =>
items.length === 0 ? (
<Combobox.Empty>Nothing matched. Try another search.</Combobox.Empty>
) : (
items.map((item) => (
<Combobox.Item
className="
flex items-center w-full py-sm px-md border-0 rounded-[calc(var(--radius-lg)_-_var(--space-xs))]
[corner-shape:squircle] bg-transparent text-inherit [font:inherit] text-sm
[line-height:var(--leading-normal)] text-start cursor-pointer select-none hover:bg-accent
hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground
focus-visible:outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
aria-[selected=true]:bg-accent aria-[selected=true]:text-accent-foreground data-[selected]:bg-accent
data-[selected]:text-accent-foreground aria-[disabled=true]:opacity-50
aria-[disabled=true]:pointer-events-none data-[disabled]:opacity-50
data-[disabled]:pointer-events-none
"
item={item}
key={item.value}
/>
))
)
}
</Combobox.Content>
</Combobox.Root>
</div>
);
}/* app.css */
@layer reset, ui, app, tokens, base, demo, components, utilities, theme;
@import "@gunkin/uiify/styles/reset";
@import "@gunkin/uiify/components/behavior.css";
@import "@gunkin/uiify/styles/tokens";
@import "@gunkin/uiify/styles/tailwind";
@import "tailwindcss/utilities.css";
@theme { --spacing: 0.25rem; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; }
@source inline('relative w-full max-w-72 flex flex-wrap items-center gap-xs min-h-10 py-sm px-md border-[length:var(--border-width)] border-solid border-input rounded-md [corner-shape:squircle] bg-card text-foreground [transition:var(--transition-colors)] focus-within:border-primary data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed has-[[data-part=input]:disabled]:opacity-50 has-[[data-part=input]:disabled]:cursor-not-allowed [&>[data-part=tag]]:inline-flex [&>[data-part=tag]]:items-center [&>[data-part=tag]]:gap-xs [&>[data-part=tag]]:py-xs [&>[data-part=tag]]:px-sm [&>[data-part=tag]]:rounded-xs [&>[data-part=tag]]:[corner-shape:squircle] [&>[data-part=tag]]:bg-accent [&>[data-part=tag]]:text-accent-foreground [&>[data-part=tag]]:text-xs [&>[data-part=tag]]:font-medium [&_[data-part=tag-remove]]:inline-grid [&_[data-part=tag-remove]]:place-items-center [&_[data-part=tag-remove]]:size-5 [&_[data-part=tag-remove]]:flex-none [&_[data-part=tag-remove]]:p-0 [&_[data-part=tag-remove]]:border-0 [&_[data-part=tag-remove]]:rounded-xs [&_[data-part=tag-remove]]:bg-transparent [&_[data-part=tag-remove]]:text-inherit [&_[data-part=tag-remove]]:[font:inherit] [&_[data-part=tag-remove]]:leading-none [&_[data-part=tag-remove]]:cursor-pointer [&_[data-part=tag-remove]:hover]:bg-primary [&_[data-part=tag-remove]:hover]:text-primary-foreground [&_[data-part=tag-remove]:focus-visible]:bg-primary [&_[data-part=tag-remove]:focus-visible]:text-primary-foreground [font-size:var(--uiify-control-text)] focus-visible:border-primary [[data-part=control]_&]:flex-[1_1_5rem] [[data-part=control]_&]:[min-inline-size:5rem] [[data-part=control]_&]:[inline-size:auto] [[data-part=control]_&]:py-xs [[data-part=control]_&]:px-0 [[data-part=control]_&]:border-0 [[data-part=control]_&]:rounded-none [[data-part=control]_&]:bg-transparent [[data-part=control]_&]:[outline:none] [[data-part=control]_&]:[box-shadow:none] [&[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)] [&_[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)] [&[popover]:popover-open:not([data-positioning=native])]:[margin:revert] [&_[popover]:popover-open:not([data-positioning=native])]:[margin:revert] flex-col [min-inline-size:12rem] [max-block-size:min(24rem,calc(100dvb_-_2rem))] overflow-y-auto overscroll-contain p-xs border-border rounded-lg shadow-popover [transform-origin:var(--transform-origin,top)] data-[state=closed]:hidden [&:where(:not([popover]),:popover-open)]:flex motion-safe:[transition:var(--transition-presence)] motion-safe:data-[state=closed]:opacity-0 motion-safe:data-[state=closed]:[scale:var(--presence-scale)] motion-safe:starting:[&:popover-open]:opacity-0 motion-safe:starting:[&:popover-open]:[scale:var(--presence-scale)] border-0 rounded-[calc(var(--radius-lg)_-_var(--space-xs))] bg-transparent text-inherit [font:inherit] text-sm [line-height:var(--leading-normal)] text-start cursor-pointer select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground aria-[selected=true]:bg-accent aria-[selected=true]:text-accent-foreground data-[selected]:bg-accent data-[selected]:text-accent-foreground aria-[disabled=true]:opacity-50 aria-[disabled=true]:pointer-events-none data-[disabled]:pointer-events-none');
@layer base { :root { --uiify-control-text: var(--text-sm); } @media (pointer: coarse) { :root { --uiify-control-text: var(--text-base); } } }
/* Authored animation and registered properties used by the Tailwind skin.
* Visual rules belong in utility recipes; only at-rule definitions live here. */
@layer components {
@keyframes uiify-presence-in {
from {
opacity: 0;
scale: var(--presence-scale);
}
}
}
@layer components {
@keyframes uiify-presence-out {
to {
opacity: 0;
scale: var(--presence-scale);
}
}
}
@property --uiify-spinner-tail {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@property --uiify-spinner-length {
syntax: "<angle>";
inherits: false;
initial-value: 36deg;
}
@layer components {
@keyframes uiify-spinner-rotate {
to {
transform: rotate(360deg);
}
}
}
@layer components {
@keyframes uiify-spinner-orbit {
0% {
--uiify-spinner-tail: 0deg;
--uiify-spinner-length: 36deg;
}
50% {
--uiify-spinner-tail: 90deg;
--uiify-spinner-length: 324deg;
}
100% {
--uiify-spinner-tail: 360deg;
--uiify-spinner-length: 36deg;
}
}
}
@layer components {
@keyframes uiify-skeleton-pulse {
50% {
opacity: 0.45;
}
}
}
@layer components {
@keyframes uiify-skeleton-shimmer {
to {
background-position: -200% 0;
}
}
}
@layer components {
@keyframes uiify-skeleton-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
@property --candle-body-height {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --candle-body-top {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --candle-slot {
syntax: "<percentage>";
inherits: true;
initial-value: 100%;
}
@property --candle-wick-height {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --candle-wick-top {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --candle-x {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --candle-scale {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
@property --line-y {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --line-prev-y {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --line-count {
syntax: "<integer>";
inherits: false;
initial-value: 1;
}
@property --line-scale {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
@layer components {
@keyframes uiify-otp-input-caret-blink {
50% {
opacity: 0;
}
}
}API
Combobox.Root
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Combobox parts: control, tags, input, trigger and content. |
items* | ComboboxItemData[] | — | Full list of options the input filters over. |
defaultInputValue | string | — | Starting text in the input when the input value is uncontrolled. |
defaultOpen | boolean | — | Whether the list starts open when the open state is uncontrolled. |
defaultValue | string | readonly string[] | — | Value or values selected on first render when the selection is uncontrolled. |
multiple | boolean | false | Allows selecting and removing multiple values as tags. |
allowCustomValue | boolean | false | Allows typed text that is not in items to become a selected value. |
addOnPaste | boolean | true | Adds multiple delimited values from pasted text in multiple mode. |
delimiters | readonly string[] | [","] | Characters that commit or split typed values in multiple mode. |
maxValues | number | — | Largest number of values that can be selected in multiple mode. |
validateValue | (value: string) => boolean | — | Rejects a custom value when it returns false. |
filterFn | (items: ComboboxItemData[], inputValue: string) => ComboboxItemData[] | — | Replaces the built-in filter that narrows items as the user types. |
inputValue | string | — | Text in the input, making the typed value controlled. |
onInputValueChange | (value: string) => void | — | Called with the new text whenever the input changes. |
onOpenChange | (open: boolean, details: OpenChangeDetails) => void | — | Called when the list opens or closes, with the reason for the change. |
onValueChange | ((value: string) => void) | ((value: string[]) => void) | — | Called with the selected value or full selected-values array. |
open | boolean | — | Whether the list is open, making the open state controlled. |
value | string | readonly string[] | — | Selected value or values, making the selection controlled. |
Combobox.Control
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Tags and input rendered inside the shared visual control. |
className | string | — | Class applied to the control element. |
render | ReactElement | ((props, state) => ReactElement) | — | Replace the rendered element while keeping behavior and accessibility wiring. |
Combobox.Tags
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Content appended after the generated removable tags. |
className | string | — | Class applied to the generated tags container. |
render | ReactElement | ((props, state) => ReactElement) | — | Replace the rendered element while keeping behavior and accessibility wiring. |
Combobox.Input
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Class applied to the text input. |
render | ReactElement | ((props, state) => ReactElement) | — | Replace the rendered element while keeping behavior and accessibility wiring. |
Combobox.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Content of the button that opens the list, such as an icon. |
className | string | — | Class applied to the trigger button. |
render | ReactElement | ((props, state) => ReactElement) | — | Replace the rendered element while keeping behavior and accessibility wiring. |
Combobox.Content
| Prop | Type | Default | Description |
|---|---|---|---|
align | "start" | "center" | "end" | — | Alignment of the list against the input along the chosen side. |
children* | (filteredItems: ComboboxItemData[]) => ReactNode | — | Render function called with the items left after filtering. |
className | string | — | Class applied to the list popup. |
side | "top" | "right" | "bottom" | "left" | — | Side of the input the list is placed on. |
Combobox.Item
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Class applied to the option element. |
item* | ComboboxItemData | — | The item this option renders, normally taken from the content callback. |
Combobox.Empty
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Content shown when filtering leaves no items. |
className | string | — | Class applied to the empty-state element. |
ComboboxItemData
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | — | Makes this item unselectable. |
label* | string | — | Text shown for the item and matched by the filter. |
value* | string | — | Value reported when the item is selected. |
Keyboard
| Key | Action |
|---|---|
| ↓ / ↑ | Open and rove options |
| Enter | Select |
| Escape | Close |
| a–z | Typeahead to filter options |
Accessibility
ARIA combobox + listbox. Active option is announced via aria-activedescendant. Content exposes open/closed state; options expose highlighted, disabled and (in multiple mode) selected state. Use Empty to explain when no options match. Popup presence motion is disabled when reduced motion is preferred.
Styling hooks
data-statedata-highlighteddata-disableddata-selected