Combobox
Combobox ARIA de valor único ou múltiplo com filtragem.
- Navegadores
- Chrome 114+Firefox 125+Safari 17+
- Nível
- 2
- Tamanho compactado
- 5.18 kB
- Limite de layout forçado
- 0 ms
- Limite do axe
- 0
- Compatível com SSR
- Sim
- Contrato publicado
- Ambiente de teste
Experimentar
Combobox
Padrão
Maçã
Banana
Cereja
Tâmara
Múltiplo
ReactUI
React
UI
TypeScript
Acessibilidade
Sistemas de design
Aberto
Maçã
Banana
Cereja
Tâmara
Grande
"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 | Tipo | Padrão | Descrição |
|---|---|---|---|
children | ReactNode | — | Partes de Combobox: controle, tags, input, acionador e conteúdo. |
items* | ComboboxItemData[] | — | Lista completa de opções filtradas pelo input. |
defaultInputValue | string | — | Texto inicial no input quando seu valor não é controlado. |
defaultOpen | boolean | — | Define se a lista começa aberta quando o estado de abertura não é controlado. |
defaultValue | string | readonly string[] | — | Valor ou valores selecionados na primeira renderização quando a seleção não é controlada. |
multiple | boolean | false | Permite selecionar e remover vários valores como tags. |
allowCustomValue | boolean | false | Permite que um texto digitado ausente em items se torne um valor selecionado. |
addOnPaste | boolean | true | Adiciona vários valores delimitados a partir de texto colado no modo múltiplo. |
delimiters | readonly string[] | [","] | Caracteres que confirmam ou separam valores digitados no modo múltiplo. |
maxValues | number | — | Número máximo de valores selecionáveis no modo múltiplo. |
validateValue | (value: string) => boolean | — | Rejeita um valor personalizado quando retorna false. |
filterFn | (items: ComboboxItemData[], inputValue: string) => ComboboxItemData[] | — | Substitui o filtro integrado que reduz os itens à medida que o usuário digita. |
inputValue | string | — | Texto no input, tornando o valor digitado controlado. |
onInputValueChange | (value: string) => void | — | Chamado com o novo texto sempre que o input muda. |
onOpenChange | (open: boolean, details: OpenChangeDetails) => void | — | Chamado quando a lista abre ou fecha, com o motivo da mudança. |
onValueChange | ((value: string) => void) | ((value: string[]) => void) | — | Chamado com o valor selecionado ou o array completo de valores selecionados. |
open | boolean | — | Define se a lista está aberta, tornando o estado de abertura controlado. |
value | string | readonly string[] | — | Valor ou valores selecionados, tornando a seleção controlada. |
Combobox.Control
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
children | ReactNode | — | Tags e input renderizados dentro do controle visual compartilhado. |
className | string | — | Classe aplicada ao elemento de controle. |
render | ReactElement | ((props, state) => ReactElement) | — | Substitua o elemento renderizado preservando comportamento e conexões de acessibilidade. |
Combobox.Tags
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
children | ReactNode | — | Conteúdo adicionado após as tags removíveis geradas. |
className | string | — | Classe aplicada ao contêiner das tags geradas. |
render | ReactElement | ((props, state) => ReactElement) | — | Substitua o elemento renderizado preservando comportamento e conexões de acessibilidade. |
Combobox.Input
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
className | string | — | Classe aplicada ao input de texto. |
render | ReactElement | ((props, state) => ReactElement) | — | Substitua o elemento renderizado preservando comportamento e conexões de acessibilidade. |
Combobox.Trigger
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
children | ReactNode | — | Conteúdo do botão que abre a lista, como um ícone. |
className | string | — | Classe aplicada ao botão acionador. |
render | ReactElement | ((props, state) => ReactElement) | — | Substitua o elemento renderizado preservando comportamento e conexões de acessibilidade. |
Combobox.Content
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
align | "start" | "center" | "end" | — | Alinhamento da lista em relação ao input ao longo do lado escolhido. |
children* | (filteredItems: ComboboxItemData[]) => ReactNode | — | Função de renderização chamada com os itens restantes após a filtragem. |
className | string | — | Classe aplicada à janela flutuante da lista. |
side | "top" | "right" | "bottom" | "left" | — | Lado do input em que a lista é posicionada. |
Combobox.Item
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
className | string | — | Classe aplicada ao elemento de opção. |
item* | ComboboxItemData | — | O item renderizado por esta opção, normalmente obtido do callback de conteúdo. |
Combobox.Empty
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
children | ReactNode | — | Conteúdo exibido quando a filtragem não deixa nenhum item. |
className | string | — | Classe aplicada ao elemento de estado vazio. |
ComboboxItemData
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
disabled | boolean | — | Impede a seleção deste item. |
label* | string | — | Texto exibido para o item e comparado pelo filtro. |
value* | string | — | Valor informado quando o item é selecionado. |
Teclado
| Tecla | Ação |
|---|---|
| ↓ / ↑ | Abra e percorra as opções |
| Enter | Selecione |
| Escape | Feche |
| a–z | Digite para filtrar opções |
Acessibilidade
Combobox ARIA + listbox. A opção ativa é anunciada por aria-activedescendant. O conteúdo expõe o estado aberto/fechado; as opções expõem os estados destacado, desabilitado e, no modo múltiplo, selecionado. Use Empty para explicar quando nenhuma opção corresponde. A animação de presença de Popup é desativada quando há preferência por movimento reduzido.
Pontos de integração de estilos
data-statedata-highlighteddata-disableddata-selected