Combobox
支持过滤的单值或多值ARIA组合框。
- 浏览器
- Chrome 114+Firefox 125+Safari 17+
- 层级
- 2
- 压缩后大小
- 5.18 kB
- 强制布局预算
- 0 ms
- axe 预算
- 0
- 支持 SSR
- 是
- 公开契约
- 测试环境
交互示例
Combobox
默认
苹果
香蕉
樱桃
椰枣
多选
ReactUI
React
UI
TypeScript
无障碍
设计系统
打开
苹果
香蕉
樱桃
椰枣
大型
"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
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
children | ReactNode | — | Combobox组成部分:控件、标签、输入框、触发器和内容。 |
items* | ComboboxItemData[] | — | 供输入框过滤的完整选项列表。 |
defaultInputValue | string | — | 输入值非受控时,输入框中的初始文本。 |
defaultOpen | boolean | — | 打开状态非受控时,列表是否初始打开。 |
defaultValue | string | readonly string[] | — | 选择状态非受控时,首次渲染所选的一个或多个值。 |
multiple | boolean | false | 允许将多个值作为标签选择和移除。 |
allowCustomValue | boolean | false | 允许将不在items中的输入文本作为所选值。 |
addOnPaste | boolean | true | 在多选模式下,从粘贴文本中添加多个由分隔符分开的值。 |
delimiters | readonly string[] | [","] | 在多选模式下,用于提交或拆分输入值的字符。 |
maxValues | number | — | 多选模式下可选择的最大值数量。 |
validateValue | (value: string) => boolean | — | 返回false时拒绝自定义值。 |
filterFn | (items: ComboboxItemData[], inputValue: string) => ComboboxItemData[] | — | 替换内置过滤器,后者会随用户输入缩小项目范围。 |
inputValue | string | — | 输入框中的文本,使输入值受控。 |
onInputValueChange | (value: string) => void | — | 输入框变化时,使用新文本调用。 |
onOpenChange | (open: boolean, details: OpenChangeDetails) => void | — | 列表打开或关闭时调用,并提供变更原因。 |
onValueChange | ((value: string) => void) | ((value: string[]) => void) | — | 使用所选值或完整的已选值数组调用。 |
open | boolean | — | 列表是否打开,使打开状态受控。 |
value | string | readonly string[] | — | 所选的一个或多个值,使选择状态受控。 |
Combobox.Control
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
children | ReactNode | — | 在共享视觉控件内渲染的标签和输入框。 |
className | string | — | 应用于控件元素的类名。 |
render | ReactElement | ((props, state) => ReactElement) | — | 替换渲染元素,同时保留行为和无障碍连接关系。 |
Combobox.Tags
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
children | ReactNode | — | 附加在自动生成的可移除标签之后的内容。 |
className | string | — | 应用于自动生成的标签容器的类名。 |
render | ReactElement | ((props, state) => ReactElement) | — | 替换渲染元素,同时保留行为和无障碍连接关系。 |
Combobox.Input
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
className | string | — | 应用于文本输入框的类名。 |
render | ReactElement | ((props, state) => ReactElement) | — | 替换渲染元素,同时保留行为和无障碍连接关系。 |
Combobox.Trigger
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
children | ReactNode | — | 打开列表的按钮内容,例如图标。 |
className | string | — | 应用于触发器按钮的类名。 |
render | ReactElement | ((props, state) => ReactElement) | — | 替换渲染元素,同时保留行为和无障碍连接关系。 |
Combobox.Content
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
align | "start" | "center" | "end" | — | 列表沿所选侧边相对于输入框的对齐方式。 |
children* | (filteredItems: ComboboxItemData[]) => ReactNode | — | 使用过滤后剩余的项目调用的渲染函数。 |
className | string | — | 应用于列表弹出层的类名。 |
side | "top" | "right" | "bottom" | "left" | — | 列表位于输入框的哪一侧。 |
Combobox.Item
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
className | string | — | 应用于选项元素的类名。 |
item* | ComboboxItemData | — | 此选项渲染的项目,通常来自content回调。 |
Combobox.Empty
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
children | ReactNode | — | 过滤后没有项目时显示的内容。 |
className | string | — | 应用于空状态元素的类名。 |
ComboboxItemData
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
disabled | boolean | — | 使此项目不可选择。 |
label* | string | — | 该项目显示的文本,也是过滤器匹配的文本。 |
value* | string | — | 选择该项目时报告的值。 |
键盘
| 按键 | 操作 |
|---|---|
| ↓ / ↑ | 打开并在选项间移动焦点 |
| Enter | 选择 |
| Escape | 关闭 |
| a–z | 键入字符以过滤选项 |
无障碍
ARIA组合框结合listbox。通过aria-activedescendant播报活动选项。Content暴露open/closed状态;选项暴露highlighted、disabled和多选模式下的selected状态。无匹配项时使用Empty作出说明。启用减少动态效果时,禁用弹出层的出现和消失动画。
样式钩子
data-statedata-highlighteddata-disableddata-selected