mcpField
B
build-primitive

build-primitive

github.com/prototyperaiデザイン・UI763 回インストール23 週間前に更新
リポジトリ

build-primitive について

Base UIに適切なコンポーネントがない場合に、Reactの独自プリミティブを作るためのガイドです。アクセシビリティ、操作、状態、スタイル、フォーム連携のパターンを扱います。

主な機能

  1. 01ARIAとセマンティックHTMLの指針
  2. 02キーボード操作とフォーカスのパターン
  3. 03制御・非制御状態
  4. 04フォーム送信への連携
  5. 05表示用・複合型テンプレート

活用シーン

  1. 01独自のカレンダーやツリービューの構築
  2. 02複合ウィジェットへのキーボード対応追加
  3. 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 Pickerreact-aria(日付プリミティブ)日付計算、ロケール、タイムゾーン
Data Table@tanstack/react-table並べ替え、フィルタリング、ページネーション、仮想化
Command Palettecmdkあいまい検索、キーボード操作、スコアリング
Carouselembla-carousel-reactスナップポイント、ドラッグの物理挙動、ループ
Resizable Panelsreact-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-valuenownumberスライダー、メーター、プログレスバー
aria-valueminnumberスライダー、メーター、プログレスバー
aria-valuemaxnumberスライダー、メーター、プログレスバー
aria-valuetextstring数値だけでは不十分な場合のスライダー(例:"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>
ルール
  1. ブール属性:true または属性ありの状態に設定するか、undefined にします("false" は使用しません)。

    data-active={isActive || undefined}      // Correct: present or absent
    data-active={isActive ? "true" : "false"} // Wrong: always present
    
  2. 列挙型属性:文字列値を使用します。

    data-state={state}           // "idle" | "loading" | "error"
    data-orientation={orientation} // "horizontal" | "vertical"
    data-side={side}             // "top" | "bottom" | "left" | "right"
    
  3. 数値属性:文字列として設定します(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}-valueProgress、Meter、Slider割合または生の値
--{component}-maxProgress、Meter最大値
--{component}-countTag 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 を指定する
フォーム(該当する場合)
  • フォーム送信のために、name prop を指定した非表示の <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}

コメント