关于 build-primitive
为 Base UI 缺少合适组件时构建自定义 React 基础组件提供指南,涵盖无障碍、交互、状态、样式和表单模式。
主要功能
- 01ARIA 与语义 HTML 指南
- 02键盘导航与焦点模式
- 03受控与非受控状态
- 04表单提交集成
- 05展示型与复合型模板
适用场景
- 01构建自定义日历或树视图
- 02为复合组件添加键盘支持
- 03让自定义控件参与表单提交
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ prototyperai/ prototyper-ui --skill build-primitive安装后在 Claude Code 中输入/build-primitive确认已加载。
详细介绍
原型 UI — 构建基础组件
当不存在 Base UI 基础组件时,如何从零开始构建一个 10/10 的组件。先定义 ARIA 契约,再实现键盘导航,然后管理状态、添加样式钩子,最后集成表单。
何时适用? 当你需要 Base UI 未提供的组件时,例如 Breadcrumb、Avatar、Badge、Card、Skeleton、Rating、Pagination、Stepper、Tag Input、File Upload、Calendar、Data Table、Carousel、Command Palette、Resizable Panels、Tree View 等。
理念: 高质量的基础组件是隐形的基础设施。它会处理所有边缘情况,让使用者无需操心。每个交互元素都必须支持键盘操作,每种状态都必须向辅助技术播报,每个动画都必须尊重 prefers-reduced-motion,每个表单控件都必须参与原生表单提交。
1. 决策关卡
从零开始构建前,请确认没有现成的方案能处理难点。
Does a Base UI primitive exist for this component?
│
├── Yes → Use /create-component skill (wrap the primitive)
│
├── Partially (e.g., Collapsible exists but not Accordion)
│ └── Compose from existing primitives + custom logic
│ (Accordion = Collapsible + custom keyboard nav + ARIA)
│
└── No primitive exists
│
├── Is it purely presentational? (Avatar, Badge, Card, Skeleton)
│ └── Section 2 — Presentational Template (simple, no hooks)
│
├── Is it a simple interactive? (Breadcrumb, Pagination, Rating)
│ └── Section 3 — Interactive Template (ARIA + keyboard)
│
├── Is it a form control? (Tag Input, File Upload, Color Picker)
│ └── Section 4 — Form Control Template (ARIA + keyboard + form)
│
└── Is it a complex composite? (Calendar, Data Table, Tree View)
└── Section 5 — Composite Template (all patterns combined)
第三方库能处理难点吗?
从零开始构建复杂交互前,请检查无头库是否提供了状态机:
| 组件 | 可考虑使用 | 原因 |
|---|---|---|
| Calendar / Date Picker | react-aria(日期基础组件) | 日期计算、本地化、时区 |
| Data Table | @tanstack/react-table | 排序、筛选、分页、虚拟化 |
| Command Palette | cmdk | 模糊搜索、键盘导航、评分 |
| Carousel | embla-carousel-react | 吸附点、拖动效果、循环 |
| Resizable Panels | react-resizable-panels | 拖动、约束、持久化 |
| Virtual Lists | @tanstack/react-virtual | 窗口化、动态高度 |
| DnD / Sortable | @dnd-kit/core | 拖动效果、碰撞检测、无障碍支持 |
如果存在无头库,请使用 Prototyper UI 的样式模式(data-slot、cn()、令牌、动画)对其进行封装,而不是重新实现状态机。
2. 展示型模板
适用于没有交互行为的组件——只包含语义化 HTML、样式和插槽。
适用于:Avatar、Badge、Card、Skeleton、Separator、AspectRatio
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
// --- Variants ---
const avatarVariants = cva(
[
"relative inline-flex items-center justify-center overflow-hidden",
"rounded-full bg-muted text-muted-foreground",
"select-none",
],
{
variants: {
size: {
sm: "size-8 text-xs",
default: "size-10 text-sm",
lg: "size-12 text-base",
},
},
defaultVariants: {
size: "default",
},
},
);
// --- Root ---
function Avatar({
className,
size,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof avatarVariants>) {
return (
<span
data-slot="avatar"
className={cn(avatarVariants({ size }), className)}
{...props}
/>
);
}
// --- Image (with fallback handling) ---
function AvatarImage({
className,
onError,
...props
}: React.ComponentProps<"img">) {
return (
<img
data-slot="avatar-image"
className={cn("aspect-square size-full object-cover", className)}
onError={onError}
{...props}
/>
);
}
// --- Fallback ---
function AvatarFallback({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center bg-muted font-medium",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback, avatarVariants };
展示型组件规则
- 使用语义化 HTML 元素(
<nav>、<ol>、<figure>、<time>、<hr>)——存在语义化元素时,绝不使用<div> - HTML 语义足够时,无需 ARIA 角色
- 无需处理键盘操作(除非包含可交互的子元素)
- Props 类型:
React.ComponentProps<"element">——而非 Base UI 类型 - 仍需包含:
data-slot、cn()合并;存在变体时使用 CVA - 仍需包含:
"use client"、具名导出、不使用 forwardRef
3. 交互型模板
适用于带有键盘交互和 ARIA 状态、但不参与表单提交的组件。
适用于:Breadcrumb、Pagination、Rating、Stepper、Disclosure、Tree View
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
// --- Controlled/Uncontrolled State Hook ---
function useControllableState<T>(
controlledValue: T | undefined,
defaultValue: T,
onChange?: (value: T) => void,
): [T, (value: T | ((prev: T) => T)) => void] {
const [internal, setInternal] = React.useState(defaultValue);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internal;
const setValue = React.useCallback(
(next: T | ((prev: T) => T)) => {
const nextValue =
typeof next === "function"
? (next as (prev: T) => T)(isControlled ? controlledValue! : internal)
: next;
if (!isControlled) setInternal(nextValue);
onChange?.(nextValue);
},
[isControlled, controlledValue, internal, onChange],
);
return [value, setValue];
}
// --- Root ---
function Rating({
className,
value: controlledValue,
defaultValue = 0,
max = 5,
onValueChange,
disabled = false,
readOnly = false,
...props
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
value?: number;
defaultValue?: number;
max?: number;
onValueChange?: (value: number) => void;
disabled?: boolean;
readOnly?: boolean;
}) {
const [value, setValue] = useControllableState(
controlledValue,
defaultValue,
onValueChange,
);
const [hovered, setHovered] = React.useState<number | null>(null);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (disabled || readOnly) return;
let next = value;
switch (event.key) {
case "ArrowRight":
case "ArrowUp":
next = Math.min(value + 1, max);
break;
case "ArrowLeft":
case "ArrowDown":
next = Math.max(value - 1, 0);
break;
case "Home":
next = 0;
break;
case "End":
next = max;
break;
default:
return;
}
event.preventDefault();
setValue(next);
},
[disabled, readOnly, value, max, setValue],
);
return (
<div
data-slot="rating"
role="slider"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={max}
aria-disabled={disabled || undefined}
aria-readonly={readOnly || undefined}
tabIndex={disabled ? -1 : 0}
className={cn(
"inline-flex gap-0.5",
"focus-visible:focus-ring rounded-sm",
disabled && "status-disabled",
className,
)}
onKeyDown={handleKeyDown}
onMouseLeave={() => setHovered(null)}
{...props}
>
{Array.from({ length: max }, (_, i) => (
<RatingItem
key={i}
index={i + 1}
filled={(hovered ?? value) >= i + 1}
onHover={() => !disabled && !readOnly && setHovered(i + 1)}
onSelect={() => !disabled && !readOnly && setValue(i + 1)}
/>
))}
</div>
);
}
// --- Item ---
function RatingItem({
filled,
onHover,
onSelect,
index,
}: {
filled: boolean;
index: number;
onHover: () => void;
onSelect: () => void;
}) {
return (
<span
data-slot="rating-item"
data-filled={filled || undefined}
aria-hidden="true"
className={cn(
"cursor-pointer text-muted transition-colors duration-150 ease-smooth",
"motion-reduce:transition-none",
"data-filled:text-primary",
"hover-only:hover:scale-110",
)}
onMouseEnter={onHover}
onClick={onSelect}
>
★
</span>
);
}
export { Rating, RatingItem };
交互模式说明
此模板展示了构建交互型组件所需的所有核心模式:
1. 受控/非受控双模式(useControllableState 钩子)
2. ARIA 角色和状态(role="slider"、aria-valuenow、aria-valuemin、aria-valuemax)
3. 键盘导航(使用方向键、Home/End 的 onKeyDown)
4. 焦点管理(tabIndex、focus-visible:focus-ring)
5. 用于样式设置的数据属性(项目上的 data-filled)
6. 禁用/只读状态(aria-disabled、条件式键盘处理)
4. 表单控件模板
适用于参与表单提交的组件——需要隐藏输入和与 Field 兼容。
适用于:Tag Input、File Upload、Color Picker、Custom Select
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
// --- useControllableState (same as Section 3, extract to shared hook) ---
function useControllableState<T>(
controlledValue: T | undefined,
defaultValue: T,
onChange?: (value: T) => void,
): [T, (value: T | ((prev: T) => T)) => void] {
const [internal, setInternal] = React.useState(defaultValue);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internal;
const setValue = React.useCallback(
(next: T | ((prev: T) => T)) => {
const nextValue =
typeof next === "function"
? (next as (prev: T) => T)(isControlled ? controlledValue! : internal)
: next;
if (!isControlled) setInternal(nextValue);
onChange?.(nextValue);
},
[isControlled, controlledValue, internal, onChange],
);
return [value, setValue];
}
// --- TagInput ---
function TagInput({
className,
name,
value: controlledValue,
defaultValue = [],
onValueChange,
disabled = false,
required = false,
maxTags,
...props
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
name?: string;
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
required?: boolean;
maxTags?: number;
}) {
const [tags, setTags] = useControllableState(
controlledValue,
defaultValue,
onValueChange,
);
const [inputValue, setInputValue] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const addTag = React.useCallback(
(tag: string) => {
const trimmed = tag.trim();
if (!trimmed || tags.includes(trimmed)) return;
if (maxTags && tags.length >= maxTags) return;
setTags([...tags, trimmed]);
},
[tags, maxTags, setTags],
);
const removeTag = React.useCallback(
(index: number) => {
setTags(tags.filter((_, i) => i !== index));
},
[tags, setTags],
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
switch (event.key) {
case "Enter":
event.preventDefault();
addTag(inputValue);
setInputValue("");
break;
case "Backspace":
if (inputValue === "" && tags.length > 0) {
removeTag(tags.length - 1);
}
break;
}
},
[disabled, inputValue, tags, addTag, removeTag],
);
return (
<div
data-slot="tag-input"
data-disabled={disabled || undefined}
className={cn(
"flex min-h-9 flex-wrap items-center gap-1.5 rounded-lg border border-field-border",
"bg-field-background px-3 py-1.5 shadow-field",
"transition-[color,background-color,border-color,box-shadow,opacity] duration-150 ease-smooth",
"motion-reduce:transition-none",
"focus-within:focus-field-ring",
"data-disabled:status-disabled",
className,
)}
onClick={() => inputRef.current?.focus()}
{...props}
>
{tags.map((tag, index) => (
<TagInputTag
key={tag}
onRemove={() => removeTag(index)}
disabled={disabled}
>
{tag}
</TagInputTag>
))}
<input
ref={inputRef}
data-slot="tag-input-field"
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
className={cn(
"min-w-[60px] flex-1 bg-transparent text-sm outline-none",
"placeholder:text-muted-foreground",
)}
aria-label="Add tag"
/>
{/* Hidden inputs for form submission */}
{name &&
tags.map((tag) => (
<input key={tag} type="hidden" name={name} value={tag} />
))}
{/* Validation: required + empty = invalid */}
{name && required && tags.length === 0 && (
<input
type="text"
name={`${name}_validation`}
required
value=""
onChange={() => {}}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
/>
)}
</div>
);
}
// --- Tag ---
function TagInputTag({
children,
onRemove,
disabled,
className,
...props
}: React.ComponentProps<"span"> & {
onRemove: () => void;
disabled?: boolean;
}) {
return (
<span
data-slot="tag-input-tag"
className={cn(
"inline-flex items-center gap-1 rounded-md bg-primary-soft px-2 py-0.5 text-xs font-medium",
"transition-[color,background-color,border-color,box-shadow,opacity] duration-150 ease-smooth",
"motion-reduce:transition-none",
className,
)}
{...props}
>
{children}
{!disabled && (
<button
data-slot="tag-input-remove"
type="button"
onClick={onRemove}
aria-label={`Remove ${children}`}
className={cn(
"inline-flex size-3.5 items-center justify-center rounded-sm",
"hover-only:hover:bg-primary-soft-hover",
"focus-visible:focus-ring",
"no-highlight",
)}
>
×
</button>
)}
</span>
);
}
export { TagInput, TagInputTag };
表单集成模式
用于表单提交的隐藏输入:
// Single value — one hidden input
<input type="hidden" name={name} value={value} />;
// Multiple values — one hidden input per value (same name)
{
values.map((v) => <input key={v} type="hidden" name={name} value={v} />);
}
使用隐藏输入进行验证:
// Required field with custom control
{
required && isEmpty && (
<input
type="text"
required
value=""
onChange={() => {}}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
/>
);
}
与字段包装器兼容:
组件必须支持由 <Field> 包装:
<Field>
<FieldLabel>Tags</FieldLabel>
<TagInput name="tags" required />
<FieldError>Please add at least one tag.</FieldError>
</Field>
要实现这一点:
- 可见输入必须通过
id关联到 Field(Field 会自动生成此属性) - 错误状态应响应父级 Field 的
data-invalid - 如果容器带有边框,请添加
data-invalid:invalid-field-ring
5. 复合组件模板
适用于具有多个交互区域、循环焦点和 ARIA 复合模式的复杂组件。
适用组件:日历、数据表、树视图、命令面板
此模板展示了循环 tabindex模式,这是所有支持方向键导航的复合小部件的基础。
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
// --- Roving Focus Hook ---
function useRovingFocus(
containerRef: React.RefObject<HTMLElement | null>,
options: {
orientation?: "horizontal" | "vertical" | "both";
loop?: boolean;
selector?: string;
} = {},
) {
const {
orientation = "both",
loop = true,
selector = "[data-roving-item]",
} = options;
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
const container = containerRef.current;
if (!container) return;
const items = Array.from(
container.querySelectorAll<HTMLElement>(selector),
).filter((el) => !el.hasAttribute("data-disabled"));
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
if (currentIndex === -1) return;
let nextIndex: number | null = null;
const isHorizontal =
orientation === "horizontal" || orientation === "both";
const isVertical = orientation === "vertical" || orientation === "both";
switch (event.key) {
case "ArrowRight":
if (isHorizontal) nextIndex = currentIndex + 1;
break;
case "ArrowLeft":
if (isHorizontal) nextIndex = currentIndex - 1;
break;
case "ArrowDown":
if (isVertical) nextIndex = currentIndex + 1;
break;
case "ArrowUp":
if (isVertical) nextIndex = currentIndex - 1;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = items.length - 1;
break;
default:
return;
}
if (nextIndex === null) return;
event.preventDefault();
// Loop or clamp
if (loop) {
nextIndex = ((nextIndex % items.length) + items.length) % items.length;
} else {
nextIndex = Math.max(0, Math.min(nextIndex, items.length - 1));
}
// Roving tabindex: deactivate current, activate next
items[currentIndex].setAttribute("tabindex", "-1");
items[nextIndex].setAttribute("tabindex", "0");
items[nextIndex].focus();
},
[containerRef, orientation, loop, selector],
);
return { handleKeyDown };
}
// --- Example: Pagination ---
function Pagination({
className,
page: controlledPage,
defaultPage = 1,
total,
onPageChange,
...props
}: Omit<React.ComponentProps<"nav">, "onChange"> & {
page?: number;
defaultPage?: number;
total: number;
onPageChange?: (page: number) => void;
}) {
const [page, setPage] = useControllableState(
controlledPage,
defaultPage,
onPageChange,
);
const containerRef = React.useRef<HTMLElement>(null);
const { handleKeyDown } = useRovingFocus(containerRef, {
orientation: "horizontal",
});
return (
<nav
ref={containerRef}
data-slot="pagination"
role="navigation"
aria-label="Pagination"
className={cn("flex items-center gap-1", className)}
onKeyDown={handleKeyDown}
{...props}
>
<PaginationItem
disabled={page <= 1}
onClick={() => setPage(page - 1)}
aria-label="Previous page"
isFirst
>
‹
</PaginationItem>
{Array.from({ length: total }, (_, i) => (
<PaginationItem
key={i + 1}
active={page === i + 1}
onClick={() => setPage(i + 1)}
aria-label={`Page ${i + 1}`}
aria-current={page === i + 1 ? "page" : undefined}
>
{i + 1}
</PaginationItem>
))}
<PaginationItem
disabled={page >= total}
onClick={() => setPage(page + 1)}
aria-label="Next page"
isLast
>
›
</PaginationItem>
</nav>
);
}
function PaginationItem({
active,
disabled,
isFirst,
isLast,
className,
...props
}: React.ComponentProps<"button"> & {
active?: boolean;
isFirst?: boolean;
isLast?: boolean;
}) {
return (
<button
data-slot="pagination-item"
data-roving-item
data-active={active || undefined}
data-disabled={disabled || undefined}
type="button"
disabled={disabled}
tabIndex={active || (isFirst && !disabled) ? 0 : -1}
className={cn(
"inline-flex size-9 items-center justify-center rounded-md text-sm",
"transition-[color,background-color,border-color,box-shadow,opacity] duration-150 ease-smooth",
"motion-reduce:transition-none",
"hover-only:hover:bg-accent hover-only:hover:text-accent-foreground",
"focus-visible:focus-ring",
"disabled:status-disabled",
"no-highlight",
"data-active:bg-primary data-active:text-primary-foreground",
className,
)}
{...props}
/>
);
}
export { Pagination, PaginationItem };
6. ARIA 角色选择
不同组件应使用哪种角色?
What does the component represent?
│
├── A clickable action?
│ ├── Navigates to URL → role not needed (<a> has implicit link role)
│ ├── Triggers action → role not needed (<button> has implicit button role)
│ └── Toggles state → role="switch" or aria-pressed="true/false"
│
├── A value in a range?
│ ├── User can change it → role="slider" (+ aria-valuenow/min/max)
│ ├── Display only, known range → role="meter" (+ aria-valuenow/min/max)
│ └── Display only, indeterminate → role="progressbar" (+ aria-valuenow or none)
│
├── A selection from options?
│ ├── Single select, dropdown → role="listbox" (items: role="option")
│ ├── Single select, inline → role="radiogroup" (items: role="radio")
│ ├── Multi-select → role="listbox" + aria-multiselectable="true"
│ └── Searchable select → role="combobox" (input) + role="listbox" (popup)
│
├── A group of related controls?
│ ├── Actions/buttons → role="toolbar"
│ ├── Navigation links → role not needed (<nav> has implicit navigation role)
│ ├── Tabs → role="tablist" (items: role="tab", panels: role="tabpanel")
│ └── Generic group → role="group" (+ aria-label)
│
├── A hierarchical structure?
│ ├── Tree of items → role="tree" (items: role="treeitem")
│ ├── Tabular data → role not needed (<table> has implicit table role)
│ └── Interactive grid → role="grid" (cells: role="gridcell")
│
├── An overlay?
│ ├── Blocks interaction → role="dialog" + aria-modal="true"
│ ├── Important message → role="alertdialog" + aria-modal="true"
│ └── Tooltip-like → role="tooltip"
│
└── A notification?
├── Urgent → role="alert" (implies aria-live="assertive")
├── Status update → role="status" (implies aria-live="polite")
└── Running log → role="log" (implies aria-live="polite")
必要的 ARIA 状态属性
| 属性 | 值 | 用于 |
|---|---|---|
aria-expanded | "true" / "false" | 展开触发器、手风琴标题、树项 |
aria-selected | "true" / "false" | 标签页、列表框选项、树项 |
aria-checked | "true" / "false" / "mixed" | 复选框、单选按钮、开关 |
aria-pressed | "true" / "false" | 切换按钮 |
aria-current | "page" / "step" / "true" | 面包屑当前项、步进器当前步骤 |
aria-disabled | "true" | 已禁用的交互元素 |
aria-hidden | "true" | 装饰性元素、文本标签旁的图标 |
aria-valuenow | 数字 | 滑块、仪表、进度条 |
aria-valuemin | 数字 | 滑块、仪表、进度条 |
aria-valuemax | 数字 | 滑块、仪表、进度条 |
aria-valuetext | 字符串 | 仅用数字不足以表达时的滑块(例如,"50%") |
aria-live | "polite" / "assertive" | 动态更新内容的区域 |
aria-modal | "true" | 会锁定焦点的对话框 |
命名规则
Does the element have visible text content?
│
├── Yes, and it's a button/link/heading → Name from content (automatic)
│
├── Yes, but text is ambiguous → aria-labelledby pointing to specific text
│ (e.g., "Read more..." → aria-labelledby="read-more heading-id")
│
├── No visible text (icon-only button) → aria-label="Close"
│
├── Form control → <label> element (explicit for= or implicit wrapping)
│
├── Group of controls → <fieldset> + <legend>
│ Or role="group" + aria-label="Group name"
│
├── Navigation region → <nav aria-label="Main navigation">
│
└── Decorative → aria-hidden="true" (remove from accessibility tree)
切勿在标签中包含角色名称。“关闭”,而不是“关闭按钮”。“导航”,而不是“导航 nav”。
7. 键盘导航模式
模式 A:单个可聚焦元素
适用于只有一个交互表面的组件(按钮、切换、评分、展开/折叠)。
Tab → Focus the element
Enter/Space → Activate
Arrow keys → Adjust value (if applicable)
function handleKeyDown(event: React.KeyboardEvent) {
switch (event.key) {
case "Enter":
case " ":
event.preventDefault();
activate();
break;
case "ArrowRight":
case "ArrowUp":
event.preventDefault();
increment();
break;
case "ArrowLeft":
case "ArrowDown":
event.preventDefault();
decrement();
break;
case "Home":
event.preventDefault();
setToMinimum();
break;
case "End":
event.preventDefault();
setToMaximum();
break;
}
}
模式 B:循环 Tabindex(复合小部件)
适用于包含多个可聚焦项、且任意时刻只有一个项位于 Tab 键顺序中的组件(工具栏、标签页、列表框、单选按钮组、分页)。
Tab → Focus the active item (only one in tab order)
Arrow keys → Move focus between items
Home → Focus first item
End → Focus last item
Enter/Space → Select/activate focused item
实现方式:参见第 5 节中的 useRovingFocus hook。
规则:
- 当前项使用
tabIndex={0},其他所有项使用tabIndex={-1} - 方向键移动焦点并更新
tabIndex值 - 初次获得焦点时,聚焦到选中项(如果没有选中项,则聚焦到第一项)
- 循环:到达末尾时绕回开头(可选,可配置)
模式 C:活动后代(组合框样式)
适用于焦点停留在输入框上,同时单独的列表显示视觉高亮项的组件(组合框、命令面板)。
Tab → Focus the input
Arrow keys → Move highlight in the list (focus stays on input)
Enter → Select highlighted item
Escape → Close popup / clear
<input
role="combobox"
aria-expanded={open}
aria-activedescendant={highlightedId}
aria-controls={listId}
aria-autocomplete="list"
onKeyDown={handleKeyDown}
/>
<ul id={listId} role="listbox">
{items.map((item) => (
<li
key={item.id}
id={item.id}
role="option"
aria-selected={item.id === highlightedId}
data-highlighted={item.id === highlightedId || undefined}
>
{item.label}
</li>
))}
</ul>
在以下情况下,请使用 aria-activedescendant,而不是循环 tabindex:
- 焦点必须停留在输入框上(组合框、搜索框)
- 列表经过虚拟化处理(并非所有项都在 DOM 中)
- 需要类型前缀搜索
模式 D:网格导航
适用于二维导航(日历、数据表)。
ArrowRight → Next cell
ArrowLeft → Previous cell
ArrowDown → Same column, next row
ArrowUp → Same column, previous row
Home → First cell in row
End → Last cell in row
Ctrl+Home → First cell in grid
Ctrl+End → Last cell in grid
PageDown → Same cell, next page/month
PageUp → Same cell, previous page/month
function handleGridKeyDown(
event: React.KeyboardEvent,
row: number,
col: number,
rows: number,
cols: number,
) {
let nextRow = row;
let nextCol = col;
switch (event.key) {
case "ArrowRight":
nextCol = col + 1;
break;
case "ArrowLeft":
nextCol = col - 1;
break;
case "ArrowDown":
nextRow = row + 1;
break;
case "ArrowUp":
nextRow = row - 1;
break;
case "Home":
nextCol = event.ctrlKey ? 0 : 0;
if (event.ctrlKey) nextRow = 0;
break;
case "End":
nextCol = event.ctrlKey ? cols - 1 : cols - 1;
if (event.ctrlKey) nextRow = rows - 1;
break;
default:
return;
}
event.preventDefault();
// Clamp or wrap
nextRow = Math.max(0, Math.min(nextRow, rows - 1));
nextCol = Math.max(0, Math.min(nextCol, cols - 1));
focusCell(nextRow, nextCol);
}
类型预判搜索
对于列表框/树形/菜单组件,输入字符即可跳转到匹配项:
function useTypeahead(
items: { label: string; element: HTMLElement }[],
options: { timeout?: number } = {},
) {
const { timeout = 500 } = options;
const searchRef = React.useRef("");
const timerRef = React.useRef<ReturnType<typeof setTimeout>>();
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
// Ignore control keys
if (
event.key.length !== 1 ||
event.ctrlKey ||
event.metaKey ||
event.altKey
)
return;
clearTimeout(timerRef.current);
searchRef.current += event.key.toLowerCase();
const match = items.find((item) =>
item.label.toLowerCase().startsWith(searchRef.current),
);
if (match) match.element.focus();
timerRef.current = setTimeout(() => {
searchRef.current = "";
}, timeout);
},
[items, timeout],
);
return { handleKeyDown };
}
8. 焦点管理
焦点陷阱(模态框)
从头构建模态框时(不使用 Base UI Dialog),必须设置焦点陷阱:
function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>) {
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const focusableSelector = [
"a[href]",
"button:not(:disabled)",
"input:not(:disabled)",
"select:not(:disabled)",
"textarea:not(:disabled)",
'[tabindex]:not([tabindex="-1"])',
].join(", ");
function handleKeyDown(event: KeyboardEvent) {
if (event.key !== "Tab") return;
const focusable = Array.from(
container!.querySelectorAll<HTMLElement>(focusableSelector),
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [containerRef]);
}
焦点恢复
关闭覆盖层时,将焦点返回到打开该覆盖层的元素:
function useFocusRestoration(isOpen: boolean) {
const triggerRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
if (isOpen) {
// Capture the currently focused element when opening
triggerRef.current = document.activeElement as HTMLElement;
} else if (triggerRef.current) {
// Restore focus when closing
triggerRef.current.focus();
triggerRef.current = null;
}
}, [isOpen]);
}
初始焦点
打开覆盖层时,将焦点移至其中第一个可聚焦元素:
React.useEffect(() => {
if (!isOpen) return;
// Delay to ensure DOM is painted
requestAnimationFrame(() => {
const container = containerRef.current;
if (!container) return;
const autofocus = container.querySelector<HTMLElement>(
"[autofocus], [data-autofocus]",
);
const firstFocusable =
container.querySelector<HTMLElement>(focusableSelector);
(autofocus ?? firstFocusable ?? container).focus();
});
}, [isOpen]);
9. 受控/非受控状态
useControllableState 钩子(见第 3 和第 4 节)是每个交互式基础组件的根基。它支持三种使用模式:
// Uncontrolled — component manages its own state
<Rating defaultValue={3} onValueChange={(v) => console.log(v)} />
// Controlled — parent manages state
<Rating value={rating} onValueChange={setRating} />
// Fully uncontrolled — no callbacks, just internal state
<Rating defaultValue={3} />
实现模式
function useControllableState<T>(
controlledValue: T | undefined,
defaultValue: T,
onChange?: (value: T) => void,
): [T, (value: T | ((prev: T) => T)) => void] {
const [internal, setInternal] = React.useState(defaultValue);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internal;
const setValue = React.useCallback(
(next: T | ((prev: T) => T)) => {
const nextValue =
typeof next === "function"
? (next as (prev: T) => T)(isControlled ? controlledValue! : internal)
: next;
if (!isControlled) setInternal(nextValue);
onChange?.(nextValue);
},
[isControlled, controlledValue, internal, onChange],
);
return [value, setValue];
}
属性约定
对于可控值,始终接受以下三个属性:
| 属性 | 用途 | 示例 |
|---|---|---|
value | 受控值(可选) | value={3} |
defaultValue | 非受控值的初始值 | defaultValue={0} |
onValueChange | 值变化时的回调 | onValueChange={(v) => setV(v)} |
将回调命名为 on{ValueName}Change,例如:onValueChange、onPageChange、onOpenChange、onCheckedChange。
10. 将数据属性用作样式钩子
创建自定义数据属性
通过 data-* 属性公开组件状态,让使用者能够通过 CSS 设置样式:
<div
data-slot="rating"
data-disabled={disabled || undefined}
data-readonly={readOnly || undefined}
data-value={value}
>
<span
data-slot="rating-item"
data-filled={filled || undefined}
data-highlighted={highlighted || undefined}
/>
</div>
规则
-
布尔属性:设为
true/存在或undefined(绝不要设为"false")data-active={isActive || undefined} // Correct: present or absent data-active={isActive ? "true" : "false"} // Wrong: always present -
枚举属性:使用字符串值
data-state={state} // "idle" | "loading" | "error" data-orientation={orientation} // "horizontal" | "vertical" data-side={side} // "top" | "bottom" | "left" | "right" -
数值属性:设为字符串(CSS 无法读取属性值,但 JS 可以读取)
data-value={String(value)}
Tailwind 定向选择
// Boolean (presence)
"data-filled:text-primary";
"data-disabled:status-disabled";
"data-highlighted:bg-accent";
// Value-based
"data-[state=loading]:animate-pulse";
"data-[orientation=vertical]:flex-col";
"data-[side=top]:slide-in-from-bottom-2";
// Group data (from parent with group class)
"group-data-disabled:opacity-50";
"group-data-checked:bg-primary";
11. 用于动态值的 CSS 变量
将计算得出的值公开为 CSS 自定义属性,供使用者设置样式:
<div
data-slot="progress"
style={
{
"--progress-value": `${(value / max) * 100}%`,
"--progress-max": String(max),
} as React.CSSProperties
}
>
<div
data-slot="progress-indicator"
className="h-full bg-primary transition-[width] duration-300"
style={{ width: "var(--progress-value)" }}
/>
</div>
常见的 CSS 变量
| 变量 | 组件类型 | 示例 |
|---|---|---|
--{component}-value | 进度条、仪表、滑块 | 百分比或原始值 |
--{component}-max | 进度条、仪表 | 最大值 |
--{component}-count | 标签输入框、分页 | 项目数量 |
--available-height | 定位覆盖层 | 溢出前的最大高度 |
--anchor-width | 定位覆盖层 | 触发元素宽度 |
--transform-origin | 定位覆盖层 | 动画原点 |
为 CSS 变量添加类型
React 的 CSSProperties 类型不包含自定义属性。请使用类型断言:
style={{ "--progress-value": `${pct}%` } as React.CSSProperties}
12. 点击外部区域与按 Escape 关闭
适用于未使用 Base UI Dialog/Popover 构建的覆盖层类组件。
function useClickOutside(
ref: React.RefObject<HTMLElement | null>,
handler: () => void,
enabled: boolean = true,
) {
React.useEffect(() => {
if (!enabled) return;
function handlePointerDown(event: PointerEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
handler();
}
}
// Use pointerdown, not click — click fires after mouseup,
// which can be on a different element than mousedown
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [ref, handler, enabled]);
}
function useEscapeKey(handler: () => void, enabled: boolean = true) {
React.useEffect(() => {
if (!enabled) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.stopPropagation(); // Prevent parent overlays from also closing
handler();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handler, enabled]);
}
同时使用两者
function Disclosure({ ... }) {
const [open, setOpen] = useControllableState(...)
const contentRef = React.useRef<HTMLDivElement>(null)
useClickOutside(contentRef, () => setOpen(false), open)
useEscapeKey(() => setOpen(false), open)
return (
<>
<button
aria-expanded={open}
aria-controls={contentId}
onClick={() => setOpen(!open)}
>
{trigger}
</button>
{open && (
<div ref={contentRef} id={contentId}>
{children}
</div>
)}
</>
)
}
13. 动画生命周期(不使用 Base UI)
从头构建覆盖层时,需要自行管理挂载/卸载动画周期。Base UI 提供 data-starting-style/data-ending-style 和 getAnimations() 检测;从头构建时,请使用以下模式:
CSS 过渡方案(首选——可中断)
function useAnimatedPresence(isOpen: boolean) {
const [mounted, setMounted] = React.useState(isOpen);
const [visible, setVisible] = React.useState(isOpen);
const ref = React.useRef<HTMLElement>(null);
React.useEffect(() => {
if (isOpen) {
setMounted(true);
// Force a layout read before setting visible (triggers transition)
requestAnimationFrame(() => {
requestAnimationFrame(() => setVisible(true));
});
} else {
setVisible(false);
// Wait for transition to finish before unmounting
const el = ref.current;
if (!el) return setMounted(false);
function handleTransitionEnd() {
setMounted(false);
el?.removeEventListener("transitionend", handleTransitionEnd);
}
el.addEventListener("transitionend", handleTransitionEnd);
return () =>
el?.removeEventListener("transitionend", handleTransitionEnd);
}
}, [isOpen]);
return { ref, mounted, visible };
}
用法:
function MyOverlay({ open }: { open: boolean }) {
const { ref, mounted, visible } = useAnimatedPresence(open);
if (!mounted) return null;
return (
<div
ref={ref}
data-slot="my-overlay"
className={cn(
"transition-[opacity,transform] duration-150 ease-out-fluid",
"motion-reduce:transition-none",
visible ? "opacity-100 scale-100" : "opacity-0 scale-95",
)}
/>
);
}
CSS 关键帧方案(用于不可中断的进入/退出动画)
如果你更喜欢关键帧动画(目前 Prototyper UI 的所有覆盖层都采用这种方式),请有条件地应用动画:
<div
className={cn(
open && "animate-in fade-in-0 zoom-in-95 duration-150",
!open && "animate-out fade-out-0 zoom-out-95 duration-100",
"motion-reduce:animate-none",
)}
onAnimationEnd={() => {
if (!open) setMounted(false);
}}
/>
14. 合并 Ref
当组件需要内部 ref,同时也必须接受用户的 ref 时:
function useMergedRef<T>(...refs: (React.Ref<T> | undefined)[]) {
return React.useCallback(
(node: T | null) => {
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === "function") ref(node);
else (ref as React.MutableRefObject<T | null>).current = node;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
refs,
);
}
// Usage
function MyComponent({
ref: userRef,
...props
}: {
ref?: React.Ref<HTMLDivElement>;
}) {
const internalRef = React.useRef<HTMLDivElement>(null);
const mergedRef = useMergedRef(internalRef, userRef);
return <div ref={mergedRef} {...props} />;
}
15. 整合所有内容——检查清单
开始之前
- 已确认此组件没有对应的 Base UI 基础组件
- 已检查是否存在可处理状态机的无头库
- 已确定 WAI-ARIA 模式(https://www.w3.org/WAI/ARIA/apg/patterns/)
- 已根据第 6 节确定正确的 ARIA 角色
HTML 与 ARIA
- 使用语义化 HTML 元素(
<nav>、<button>、<input>、<ol>)——存在语义化元素时绝不使用<div> - 在根元素或主要交互元素上设置正确的 ARIA 角色
- 设置所有必需的 ARIA 状态(
aria-expanded、aria-selected、aria-checked等) - 交互元素具有可访问名称(可见文本、
aria-label或<label>) - 装饰性元素设置
aria-hidden="true" - 为动态内容更新设置实时区域(
aria-live) - 自定义控件使用
aria-disabled(而不只是 HTMLdisabled)
键盘
- 所有功能均可通过键盘使用
- 使用正确的键盘交互模式(单一焦点、roving tabindex 或 active descendant)
- 使用方向键在复合控件中导航
- 使用 Home/End 键跳转到第一个/最后一个项目
- 使用 Enter/Space 键激活
- 使用 Escape 键关闭(覆盖层)
- Tab 顺序合理(每个复合控件只有一个 Tab 停靠点)
- 对已处理的按键调用
event.preventDefault()(防止方向键触发滚动)
焦点
- 在可聚焦元素上使用
focus-visible:focus-ring - 在模态框中锁定焦点(
useFocusTrap) - 关闭覆盖层时恢复焦点(
useFocusRestoration) - 打开覆盖层时设置初始焦点
- 禁用项应不可聚焦,或可聚焦但不响应操作(取决于交互模式)
状态
- 通过
useControllableState支持受控/非受控模式 - 属性遵循
value/defaultValue/onValueChange约定 - 通过数据属性暴露状态,以便 CSS 定位
- 为动态计算值使用 CSS 变量
样式
- 每个渲染的元素都设置
data-slot - 使用
cn()合并类名,并将用户的className放在最后 - 使用语义化 token(不使用原始颜色,也不使用
dark:工具类) - 所有悬停状态都添加
hover-only:hover:前缀 - 使用
motion-safe:active:scale-[0.97]提供按压反馈 - 所有过渡效果都添加
motion-reduce:transition-none - 所有动画都添加
motion-reduce:animate-none - 在交互元素上添加
no-highlight - 在可禁用元素上添加
disabled:status-disabled
表单(如适用)
- 使用带有
name属性的隐藏<input>提交表单 - 通过隐藏的必填输入框或约束 API 实现
required验证 - 被
<Field>组件包裹时也能正常工作 - 错误状态会响应 Field 的
data-invalid
结构
- 将
"use client"放在第 1 行 - 导入顺序:react → cva → icons → cn → local
- 仅使用命名导出,并集中在单个
export { }代码块中 - 不使用
React.forwardRef(通过属性或useMergedRef处理 ref) - 不使用
export default
16. 交叉引用
- 包装现有的 Base UI 原语:调用
/create-componentskill - 动画模式和精确类名字符串:调用
/animate-uiskill - 设计 token、组件选择和使用模式:调用
/prototyper-uiskill - WAI-ARIA 编写实践:https://www.w3.org/WAI/ARIA/apg/patterns/
- Base UI 原语:https://base-ui.com/react/components/{name}