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を入力して読み込まれたか確認します。
詳細
Prototyper UI — プリミティブコンポーネントの構築
Base UI にプリミティブが存在しない場合に、コンポーネントをゼロから最高品質で構築する方法です。まず 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 複合パターンを持つ複雑なコンポーネント向けです。
使用例:Calendar、Data Table、Tree View、Command Palette
このテンプレートでは、矢印キーによるナビゲーションを行うあらゆる複合ウィジェットの基盤となるロービング 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 | number | スライダー、メーター、プログレスバー |
aria-valuemin | number | スライダー、メーター、プログレスバー |
aria-valuemax | number | スライダー、メーター、プログレスバー |
aria-valuetext | string | 数値だけでは不十分な場合のスライダー(例:"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)
ラベルにロール名を決して含めないでください。「Close button」ではなく「Close」、「Navigation nav」ではなく「Navigation」とします。
7. キーボードナビゲーションパターン
パターン A:フォーカス可能な要素が 1 つ
インタラクティブな面が 1 つのコンポーネント(Button、Toggle、Rating、Disclosure)向けです。
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(複合ウィジェット)
複数のフォーカス可能な項目があり、一度にタブ順に入るのは 1 つだけのコンポーネント(Toolbar、Tabs、Listbox、Radio Group、Pagination)向けです。
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 フックを参照してください。
ルール:
- アクティブな項目には
tabIndex={0}を設定し、それ以外の項目にはtabIndex={-1}を設定します - 矢印キーでフォーカスを移動し、
tabIndexの値も更新します - 初回フォーカス時は、選択中の項目(選択中の項目がない場合は最初の項目)に移動します
- ループ:末尾に達したら先頭に戻ります(任意、設定可能)
パターン C:アクティブディセンダント(コンボボックス形式)
入力要素にフォーカスを保ったまま、別のリストに視覚的なハイライトを表示するコンポーネント(Combobox、Command Palette)向けです。
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>
ロービング tabindex の代わりに**aria-activedescendant を使用する**場合:
- フォーカスを入力要素に保つ必要がある場合(コンボボックス、検索ボックス)
- リストが仮想化されている場合(すべての項目が DOM 内にあるとは限らない)
- 入力中検索が必要な場合
パターン D:グリッドナビゲーション
2 次元のナビゲーション(Calendar、Data Table)向けです。
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 に記載)は、すべてのインタラクティブなプリミティブの基盤です。次の 3 つの使用モードに対応しています。
// 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];
}
Props の規約
制御可能な値には、必ず次の 3 つの props を受け取るようにします。
| Prop | 用途 | 例 |
|---|---|---|
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 | Progress、Meter、Slider | 割合または生の値 |
--{component}-max | Progress、Meter | 最大値 |
--{component}-count | Tag Input、Pagination | 項目数 |
--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)を使用する - カスタムコントロールには(HTML の
disabledだけでなく)aria-disabledを指定する
キーボード
- すべての機能にキーボードでアクセスできる
- 適切なキーボード操作パターンを使用する(単一のフォーカス、ロービング tabindex、またはアクティブな子孫)
- コンポジット内の移動に矢印キーを使用する
- 最初/最後の項目への移動に Home/End を使用する
- 操作に Enter/Space を使用する
- オーバーレイの解除に Escape を使用する
- タブ順序が適切である(コンポジットウィジェットごとにタブストップは 1 つだけ)
- 処理するキーには
event.preventDefault()を指定する(矢印キーによるスクロールを防止)
フォーカス
- フォーカス可能な要素に
focus-visible:focus-ringを指定する - モーダル内にフォーカスを閉じ込める(
useFocusTrap) - オーバーレイを閉じたときにフォーカスを復元する(
useFocusRestoration) - オーバーレイを開いたときに初期フォーカスを設定する
- 無効な項目をフォーカス不可にするか、フォーカス可能だが操作不能にする(パターンによる)
状態
-
useControllableStateを使用して、制御/非制御の両方に対応する - Props は
value/defaultValue/onValueChangeの規約に従う - CSS セレクターで対象にできるよう、状態をデータ属性で公開する
- 動的に計算される値には CSS 変数を使用する
スタイリング
- レンダリングされるすべての要素に
data-slotを指定する -
cn()でマージし、ユーザーのclassNameを最後に指定する - セマンティックトークンを使用する(生の色や
dark:ユーティリティは使用しない) - すべてのホバー状態に
hover-only:hover:プレフィックスを付ける - 押下時のフィードバックに
motion-safe:active:scale-[0.97]を指定する - すべてのトランジションに
motion-reduce:transition-noneを指定する - すべてのアニメーションに
motion-reduce:animate-noneを指定する - インタラクティブ要素に
no-highlightを指定する - 無効化できる要素に
disabled:status-disabledを指定する
フォーム(該当する場合)
- フォーム送信のために、
nameprop を指定した非表示の<input>を使用する - 非表示の必須入力または制約 API で
requiredの検証を行う -
<Field>コンポーネントでラップした場合も動作する - Field の
data-invalidに応じてエラー状態を反映する
構造
- 1 行目に
"use client"を記述する - インポート順序:react → cva → アイコン → cn → ローカル
- 名前付きエクスポートのみを使用し、単一の
export { }ブロックにまとめる -
React.forwardRefを使用しない(prop またはuseMergedRefで refs を処理する) -
export defaultを使用しない
16. 相互参照
- 既存の Base UI プリミティブをラップする場合:
/create-componentスキルを呼び出す - アニメーションのパターンと正確なクラス文字列:
/animate-uiスキルを呼び出す - デザイントークン、コンポーネントの選択、使用パターン:
/prototyper-uiスキルを呼び出す - WAI-ARIA オーサリングプラクティス:https://www.w3.org/WAI/ARIA/apg/patterns/
- Base UI プリミティブ:https://base-ui.com/react/components/{name}