# はじめに (/docs) sashigane は、\*\*デザイントークン(CSS 変数)\*\*と、それを使う **React コンポーネント**の セットです。 npm パッケージとしてインストールするのではなく、[`shadcn`](https://ui.shadcn.com/) の CLI で **ソースコードが自分のリポジトリにコピーされます。** コピーされた時点で自分のコードなので、 そのまま書き換えられます。 ## すぐ試す [#すぐ試す] 配信元を `components.json` に1度書きます。**これが無いと動きません。** ```json { "registries": { "@sashigane": "https://sashigane-docs.vercel.app/r/{name}.json" } } ``` あとは欲しいものを名前で足すだけです。 ```bash npx shadcn@latest add @sashigane/button ``` コピー先や、まとめて入れる方法は [導入する](/docs/install) にあります。 ## このデザインシステムの特徴 [#このデザインシステムの特徴] ### 色を選ばなくても読める [#色を選ばなくても読める] 色は OKLCH で作ってあり、明度の段が揃っています。**役割の名前で書けば、 ライトモードでもダークモードでも読める組み合わせになります。** 面の深さは `data-sg-surface` で宣言します。宣言すると、**背景と前景が一緒に** 深くなります。 ```html
…
``` 背景だけを塗る方法は用意していません。塗れてしまうと前景が浅いまま残り、 **読みにくい組み合わせができても何も起きない**からです。 ### サイズも名前で書ける [#サイズも名前で書ける] 余白・字の大きさ・角丸・影は、`root = 16px` を基点に**1つの規則から作られています。** 値を選ぶ場面がないので、別々に作った画面でもサイズが揃います。 ### React が要らない使い方もできる [#react-が要らない使い方もできる] トークンだけを入れれば、**素の CSS で使えます。** 面の宣言・塗り・オーバーレイ・ スケルトンの動きまで CSS に入っているので、React 以外でも動きます。 ```bash npx shadcn@latest add @sashigane/tokens ``` ### コンポーネントは自分の名前を持つ [#コンポーネントは自分の名前を持つ] すべてのコンポーネントが `data-sg-component` に自分の名前を出します。**見た目は持ちません。** ```html )} 住所の変更は、次の請求が確定するまで反映されません。すでに確定している請求は 古い住所のまま送られます。急ぎのときは、請求の担当へ直接お問い合わせください。 {shown ? ( 宛先のうち3件が見つかりませんでした。宛先を確かめてください。 ) : null} ); } ``` # Badge (/docs/components/badge) ## 色は宣言から来ます [#色は宣言から来ます] **中立は凹んだ面を宣言します。** 色付きは**淡い塗り**を使います。 どちらも背景と文字が対で決まっているので、**背景だけを塗る道はありません。** ```tsx 中立 危険 ``` トーンは塗りを持つものすべて(`accent` / `danger` / `warning` / `success` / `info`)に加えて、 色を持たない `neutral` があります。**既定は `neutral` です。** ## 枠線が付きます [#枠線が付きます] **凹んだ面の上に置くと、中立のバッジは背景と同じ色になります。** 面の段は凹んだところで底に着くので、その上に凹んだ面を宣言しても深くなりません。 境界があれば、背景が同じでもバッジの輪郭が読めます。 ## Badge は押せません [#badge-は押せません] hover も focus も持ちません。**押せるようにしたいなら Button を使ってください。** リンクにしたいときは `asChild` で `a` を渡せます。**そのときも見た目は変わりません**—— 押せることが見た目から分からないので、押す前提の導線には向きません。 ```tsx タグへ ``` ## 大きさは2段です [#大きさは2段です] 小さい方は表の行や見出しの脇に、大きい方は本文と並べて使います。 ## 折り返しません [#折り返しません] バッジは1行に収まる前提です。**長い語は枠の側で切るか、そもそも短い語だけを載せてください。** ### 例: default ```tsx import { Badge } from '../badge.tsx'; /** * 通常。**色は宣言から来る。** * * 中立は凹んだ面を宣言する。色付きは淡い塗りを使う。 * どちらも背景と文字が対で決まっているので、**背景だけを塗る道は無い。** */ export default function Default() { return (
中立 強調 危険 注意 成功 情報
小さい方 表の行の脇に 大きい方 本文と並べて
); } ``` ### 例: empty ```tsx import { Badge } from '../badge.tsx'; /** * 空。**中身が無くても枠は潰れない。** * * 左右の余白が残るので、点として見える。 * 何も無いバッジを出す意味は薄いが、**組み立て中に文字が届かないことはある。** */ export default function Empty() { return (
); } ``` ### 例: edge ```tsx import { Badge } from '../badge.tsx'; /** * エッジケース。**長い語と、面の上での見え方。** * * バッジは折り返さない。**枠の側で切るか、そもそも短い語だけを載せる。** * * 凹んだ面の上に中立のバッジを置くと、バッジも1段深い段を指すので沈んで見える。 * **面の仕組みに乗っているためで、書き直しは要らない。** */ export default function Edge() { return (
Supercalifragilisticexpialidocious
凹んだ面の上
); } ``` ### 例: as-child ```tsx import { Badge } from '../badge.tsx'; /** * **バッジをリンクにする。** * * `asChild` を付けると、この枠は要素を1つも作らず、 * クラスと属性を子へ移して子だけを描く。**見た目は変わらない。** * * 押せるようにしたいなら Button を使う。 * バッジは hover も focus も持たないので、**押せることが見た目から分からない。** */ export default function AsChild() { return (
タグへ 別のタグへ
); } ``` # Breadcrumb (/docs/components/breadcrumb) ```tsx ホーム ドキュメント はじめに ``` ## 区切り記号は Breadcrumb が入れます [#区切り記号は-breadcrumb-が入れます] **Breadcrumb が項目のあいだに入れます。** 書く必要はありません。 自分で `` を並べる形にしていないのは、 **読み上げから隠す指定を付け忘れられる**からです。忘れても見た目は変わらないので、 読み上げが「スラッシュ」を項目の数だけ読む状態が黙って残ります。 形だけは変えられます。 ```tsx … ``` **先頭には出ません。** あいだにしか入らないので、記号が浮くことがありません。 ## 現在地の指定も Breadcrumb が付けます [#現在地の指定も-breadcrumb-が付けます] **末尾がいま居る場所です。** パンくずの形がそう決めているので、 `aria-current="page"` は Breadcrumb が付けます。 書かせる形にすると、**階層を組み替えたときに付け替え忘れます。** 見た目には出ないので、そのまま残ります。 ```tsx ドキュメント はじめに {/* ← ここに付きます */} ``` 末尾に `href` を渡してもかまいません。**リンクのまま現在地であることを示します。** ## 行き先が無ければリンクになりません [#行き先が無ければリンクになりません] `href` を渡さなかった項目は文字だけになります。 **押せるように見えて何も起きないものを作りません。** ## 長いときは折り返します [#長いときは折り返します] **畳みません。** `…` で途中を省く形は持っていません。 省くとなると、**どれを畳むかを Breadcrumb が決める**ことになります。どの階層を省けるかは、 その階層を作った側にしか分かりません。 畳んだ先を開く仕掛けも要ります。 幅が足りないときは折り返します。 ## 好きなリンクコンポーネントに差し替えられます [#好きなリンクコンポーネントに差し替えられます] `Link` を使っているときは `asChild` を渡してください。 **枠を1つも作らず、名前と現在地の申告が子へ移ります。** ```tsx ドキュメント ``` ### 例: default ```tsx import { Breadcrumb, BreadcrumbItem } from '../breadcrumb.tsx'; /** * 通常。**区切りは書かない。Breadcrumb が入れる。** * * 末尾がいま居る場所である。`aria-current="page"` はBreadcrumb が付けるので、 * **階層を組み替えても付け替え忘れが起きない。** */ export default function Default() { return ( ホーム ドキュメント コンポーネント パンくずリスト ); } ``` ### 例: empty ```tsx import { Breadcrumb, BreadcrumbItem } from '../breadcrumb.tsx'; /** * 空。**いま居る場所しか無いとき。** * * 1つだけでもパンくずリストとして成立する。**区切りは出ない**—— * Breadcrumb はあいだにしか入れないので、先頭に記号が浮くことがない。 */ export default function Empty() { return ( ホーム ); } ``` ### 例: edge ```tsx import { Breadcrumb, BreadcrumbItem } from '../breadcrumb.tsx'; /** * エッジケース。**階層が深いとき・区切り記号の差し替え・要素の差し替え。** * * 長いときは折り返す。**畳まない**——どれを畳むかは、 * 階層を作った側にしか決められない。 */ export default function Edge() { return (
ホーム 設定 アカウント 通知の設定 メールで知らせる条件 ホーム ドキュメント 導入する ホーム ドキュメント いま見ているページ
); } ``` # Button (/docs/components/button) ## 押している最中の見た目は持ちません [#押している最中の見た目は持ちません] 押している最中だけ見た目を変える仕組みは入れていません。hover と focus で足りると判断しています。 必要になった場合は、面の深さで表すことになります——ただし**一番深い面には行き先が無い**ので、 そこは設計として解く必要があります。いまは持っていません。 ## 無効のときは面を暗くします [#無効のときは面を暗くします] 不透明度は使いません。薄めると前景と背景が同時に下地へ寄り、**読みやすさの保証が効かなくなる** ためです。 代わりに、無効のときだけ**凹んだ面**を宣言して文字を淡くします。 面の仕組みに乗るので、**塗り・淡い塗り・枠・文字だけのどれでも同じ形で暗くなります。** 塗りが残って「押せそうに見えて押せない」状態になりません。 ## hover は4種すべてで変わります [#hover-は4種すべてで変わります] 押せることが見た目から分かる必要があるので、**塗り方によらず hover で背景が変わります。** | 塗り方 | hover でどうなるか | | ------------------- | ------------------------ | | `solid` | 塗りが1段深くなる | | `subtle` | **色味を保ったまま**1段深くなる | | `outline` / `ghost` | 面が1段深くなる(塗りが無いので、面の側で出す) | `subtle` だけは自分で塗っているため、面の hover の中立色に上書きされないよう 自分の塗りを優先しています。**上書きされると、hover した瞬間に色味が消えて灰色になります。** ## 塗りは宣言します [#塗りは宣言します] 塗りのボタンは**色を1つも書きません。** 面と同じで、**宣言が背景・境界・文字を同時に与えます。** ```tsx {/* data-sg-fill="accent" が付く */} ``` 塗りの段は**テーマでも面でも変わりません。** ブランドの色はテーマで変わるものではない、 という線引きです。文字は明るい端に揃い、**ライトモードでもダークモードでも同じ見え方**になります。 **コンポーネントの識別は境界が担います。** 深い面の上では塗りだけでは地から離れないので、 境界を面ごとに変えています。hover では**中だけが濃くなり、境界は動きません**—— だから hover 中もコンポーネントの輪郭は保たれます。 ## 色は1つも書いていません [#色は1つも書いていません] 塗りの色も、その上に載る文字の色も、hover でずらす先も、すべてトークンが持っています。 トーンは**塗りを持つものすべて**(`accent` / `danger` / `warning` / `success` / `info`)に出します。 ## 枠の色は中立です [#枠の色は中立です] `outline` の境界はトーンの色になりません。**色はトーンに、境界は面の骨格に属する**という 線引きです。揃え忘れではありません。 ## アイコン [#アイコン] アイコンは持っていません。**利用側が好きなものを子として置きます。** 文字と並べるときは枠が間を空けるので、そのまま入れれば揃います。 ```tsx ``` **文字と並べるアイコンは読み上げから隠します。** 文字が既に同じことを言っているので、 隠さないと二重に読まれます。 ### アイコンだけのボタンには名前が要ります [#アイコンだけのボタンには名前が要ります] 目に見える文字が無いので、**読み上げに渡すものがありません。** `aria-label` か `aria-labelledby` を**型で必須にしてあります。** ```tsx ``` 書き忘れても**エラーは出ず、見た目も正常**です。読み上げが「ボタン」としか言わなくなるだけなので、 型で塞ぐしかありません。 ### アイコンの大きさは行の高さに合わせます [#アイコンの大きさは行の高さに合わせます] `iconOnly` は左右の余白を詰めるだけで、**上下の余白は文字のときと同じ**です。 つまり**中身の高さが、そのまま枠の高さの差になります。** 行の高さ(`size-6` = 24px)に合わせると、枠は正方形になり、文字のボタンとも高さが揃います。 小さいアイコンを入れると枠も小さくなります。 アイコンのサイズは**属性ではなくクラスで書いてください。** `width="24"` と書くと、 スケールの外の値を書けてしまいます。 **正方形に固定していません。** 文字のボタンの高さはスケールの段の上に乗っていないので、 縦横を段で指定すると**横に並べたときにかえって揃わなくなります。** ## ローディング中 [#ローディング中] `loading` を付けるとスピナーが出て、押せなくなります。 ```tsx ``` ### 暗くはしません [#暗くはしません] **無効は「できない」で、ローディング中は「いま起きている」です。** 同じ見た目にすると2つの状態が区別できないので、ローディング中は塗りをそのまま残します。 暗くなるのは無効のときだけです。 ### 文字は残ります [#文字は残ります] スピナーだけにすると、**何を待っているのか分からなくなります。** ただし**アイコンだけのボタンではスピナーが置き換えます**——並べると枠が横に伸びて、 正方形でなくなるためです。 ### 枠はスピナーのぶんだけ横に伸びます [#枠はスピナーのぶんだけ横に伸びます] 文字を残すので、**押した瞬間にボタンが広がり、隣のものが動きます。** ずれてほしくない場所では、枠の幅を先に決めておいてください。 アイコンだけのボタンではスピナーが置き換えるので伸びません。 ### 無効と両方渡すと、ローディング中が勝ちます [#無効と両方渡すとローディング中が勝ちます] `disabled={!valid || submitting}` と `loading={submitting}` を並べて書く形が 普通にあるので、型では塞いでいません。 **暗くなった上でスピナーが回ると「できないのに待っている」になり、意味が通りません。** ローディング中の方が短く、いま起きていることなので、そちらを見せます。 押せないことは変わりません。 ### 押せなくなります [#押せなくなります] スピナーが回っているだけで押せると、**二重に送れてしまいます。** `aria-busy` も付きます。 スピナーは読み上げから隠れています。ボタンは文字か `aria-label` で既に名前を持っているので、 隠さないと**二重に読まれます。** ### `loading` と `asChild` は同時に使えません [#loading-と-aschild-は同時に使えません] `disabled` と同じ理由です。押せなくする手段がリンクにはありません。 ## リンクをボタンの見た目にできます [#リンクをボタンの見た目にできます] 見た目はボタン、実体はリンクという形はよくあります。 `asChild` を付けると、**Button は要素を1つも作らず**、クラスと属性を子へ移して子だけを描きます。 ```tsx ``` 描かれるのは `` 1つです。**`button` の中に `a` が入る形にはなりません。** 子に渡せるのは**操作できる要素1つだけ**です。複数の中身をまとめるときは、その要素の**内側**に入れてください。 外側を `div` で包むと、**押せない `div` がボタンの見た目になります。** ```tsx {/* こう書く */} ``` ### `asChild` と `disabled` は同時に使えません [#aschild-と-disabled-は同時に使えません] 型で禁止してあります。押せない状態は `button` 要素の機能で、リンクにはありません—— `disabled` を `a` に付けても**エラーは出ず、何も起きません。** 暗い見た目のまま押せてしまうので、**「押せそうに見えて押せない」の逆**という、 より悪い状態になります。 押せない状態が要るなら `button` のままにしてください。 リンクを押せなくしたいなら、**リンクを出さないのが正しい形です。** ### 例: default ```tsx import { Button } from '../button.tsx'; /** * 通常。**塗り方4種とトーン5つの組み合わせ。** * * 色は1つも決めていない。塗りの色も、その上に載る文字の色も、 * hover でずらす先も、すべてトークンが持っている。 */ export default function Default() { return (
); } ``` ### 例: empty ```tsx import { Button } from '../button.tsx'; /** * 空。**ラベルが無くても枠として成立すること。** * * アイコンだけのボタンはここに来る。**押せるものが見えていること**を見る—— * 塗りは面から独立しているので、中身が無くても背景と境界は残る。 */ export default function Empty() { return ); } ``` ### 例: as-child ```tsx import { Button } from '../button.tsx'; /** * **見た目はボタン、中身はリンク。** * * `asChild` を付けると、この枠は要素を1つも作らず、 * クラスと属性を子へ移して子だけを描く。 * ここで実際に描かれているのは `` だけで、` ); } ``` ### 例: icon ```tsx import { Button } from '../button.tsx'; import { IconX } from '../../icon/icon.tsx'; /** * **アイコンと一緒に使う。** * * アイコンは Icon で包んである。**図形は lucide から来る。** * * 文字と並べるときは、アイコンを読み上げから隠す—— * **文字が既に同じことを言っている**ので、二重に読まれる。 * * アイコンだけのときは逆で、**名前を別に与えないと読み上げに渡すものが無い。** * `aria-label` か `aria-labelledby` を型で必須にしてある。 * * ## アイコンの大きさは行の高さに合わせる * * 上下の余白は文字のときと同じなので、**中身の高さがそのまま枠の高さの差になる。** * 行の高さに合わせると、正方形になり、文字のボタンとも高さが揃う。 * Icon の既定がその大きさなので、**何も指定しなければ揃う。** * * 小さい段を選ぶと枠も小さくなる。下の列で見比べられる。 */ export default function Icon() { return (
← 行の高さより小さいと、枠も小さくなります
); } ``` ### 例: loading ```tsx import { Button } from '../button.tsx'; /** * **ローディング中。** * * スピナーを出し、押せなくする。**暗くしない**—— * 無効は「できない」で、ローディング中は「いま起きている」である。 * 同じ見た目にすると区別が付かない。 * * 文字はそのまま残る。スピナーだけにすると、何を待っているのか分からなくなる。 * **アイコンだけのボタンではスピナーが置き換える**——並べると枠が横に伸びてしまう。 * * 下の列に無効を並べてある。**沈み方が違う**ことが見比べられる。 */ const path = 'M4 4l16 16M20 4l-16 16'; const Close = () => ( ); export default function Loading() { return (
← 暗くなるのは「できない」ときだけです
); } ``` # Calendar (/docs/components/calendar) ```tsx ``` ## 言語は渡してください [#言語は渡してください] 月名・曜日名・週の開始は言語で変わります。**既定を持っていません**—— 書体名と同じで、**ブランドの選択**だからです。 ```tsx import { ja } from 'react-day-picker/locale'; ``` 渡さなければ英語になります。 ## react-day-picker の CSS は読み込みません [#react-day-picker-の-css-は読み込みません] このコンポーネントは [`react-day-picker`](https://daypicker.dev/) に乗っていますが、**そちらの CSS は読み込みません。** 持っている値がこのスケールの外にあるためです。 **あなたも読み込まないでください。** 読み込むとサイズと色が二重になります。 ```tsx import 'react-day-picker/style.css'; {/* 要りません */} ``` ## 選び方は `mode` で決まります [#選び方は-mode-で決まります] | `mode` | 選べるもの | | ---------- | -------- | | `single` | 1日 | | `range` | 範囲 | | `multiple` | 飛び飛びの複数日 | `selected` と `onSelect` の型は `mode` に連動します。 **範囲のつもりで1日を渡すと型で落ちます。** ```tsx ``` ## 範囲は端と中で濃さが違います [#範囲は端と中で濃さが違います] 端は濃く、あいだは淡く塗ります。**同じ濃さにすると、端がどこか読めません。** 端は外側だけ丸めるので、**中と繋がって1本に見えます。** ## 選べない日は暗くなります [#選べない日は暗くなります] `disabled` に渡した日は押せなくなります。 ```tsx ``` **押せるように見えて何も起きないものを作りません。** ## 依存が3つインストールされます [#依存が3つインストールされます] Calendar を追加すると、`react-day-picker` と [`date-fns`](https://date-fns.org/) と `@date-fns/tz` も 一緒にインストールされます。**Calendar を使うときだけ**です。 単一の日付だけなら自分で書ける規模ですが、**範囲まで持つと 日付のマス目の中でのフォーカス移動・選択の申告・月を移ったときの読み上げ**が 一気に増えます。 そこを引き受けてもらうための依存です。 ### 例: default ```tsx 'use client'; /* **例そのものをクライアント側にする。** 選んだ日を覚えるのに状態が要る。 */ import { useState } from 'react'; import { ja } from 'react-day-picker/locale'; import { Calendar } from '../calendar.tsx'; /** * 通常。**1日を選ぶ。** * * 言語は利用側が渡す。**既定を持たない**——月名も曜日名も週の開始も、 * ブランドの選択だからである。 */ export default function Default() { const [selected, setSelected] = useState(new Date(2026, 8, 15)); return ( ); } ``` ### 例: empty ```tsx 'use client'; /* **例そのものをクライアント側にする。** 何も選ばれていない状態を見せる。 */ import { ja } from 'react-day-picker/locale'; import { Calendar } from '../calendar.tsx'; /** * 空。**何も選ばれていないとき。** * * マス目は残る。**今日だけが境界で分かる**——選ばれていないことと、 * 暦が出ていないことは違う。 */ export default function Empty() { return ; } ``` ### 例: edge ```tsx 'use client'; /* **例そのものをクライアント側にする。** 範囲の選択に状態が要る。 */ import { useState } from 'react'; import { ja } from 'react-day-picker/locale'; import type { DateRange } from 'react-day-picker'; import { Calendar } from '../calendar.tsx'; /** * エッジケース。**範囲・複数月・選べない日。** * * 範囲の端は濃く、あいだは淡い。**同じ濃さにすると端が読めない。** * * 選べない日は暗くする。**押せるように見えて何も起きないものを作らない。** */ export default function Edge() { const [range, setRange] = useState({ from: new Date(2026, 8, 8), to: new Date(2026, 8, 17), }); return (
); } ``` # Card (/docs/components/card) 面の宣言(`data-sg-surface`)の使い方を決めるコンポーネントです。 ## 面は `data-sg-surface` で宣言します [#面は-data-sg-surface-で宣言します] Card は背景色を1つも書きません。背景も文字色も境界色も、面の宣言から来ます。 ```tsx 内容 ``` 背景だけを塗る方法を用意していないのは、**塗れてしまうと前景が親の面のまま残り、 読みにくい組み合わせができてもエラーにならない**ためです。 ## hover も塗りではなく宣言で切り替えます [#hover-も塗りではなく宣言で切り替えます] `interactive` は面の文脈を1段深くするだけで、背景を直接塗りません。 背景だけを深くすると前景が置き去りになるので、塗る道は用意していません。 ## overlay には影が要ります [#overlay-には影が要ります] `overlay` は通常の面と**同じ深さ**に置いてあります。そのため影が無いとダークモードで下地と同化します。 **`surface="overlay"` を選ぶと影が自動で付きます**——「付けられる」ではなく 「無しでは組み立てられない」形にしてあります。 ## 中に置けるセクション [#中に置けるセクション] 見出しと操作はセクションで分けます。**セクションは面も色も持ちません**—— 面を宣言するのは枠だけです。 ```tsx 見出し 補足

本文

``` セクションは**必須ではありません。** 要るセクションだけ置けば足ります。 枠は中身を縦に並べて間を空けるだけなので、セクションを使わずに直接書いても崩れません。 ### 本文のセクションはありません [#本文のセクションはありません] **本文は枠の直下に置きます。** 一度 `CardBody` を用意しましたが、 クラスを1つも持たない `div` でした——書かなくても崩れず、書いても何も起きません。 枠が縦に並べて間を空けるので、それで足ります。 ### 見出しのレベルは変えられます [#見出しのレベルは変えられます] `CardTitle` は既定で `h3` になります。**カードをページのどこに置くかで正しいレベルは変わる** ので、`asChild` で差し替えられます。 ```tsx

ページ直下に置くとき

``` ### 操作は下端に寄ります [#操作は下端に寄ります] `CardFooter` は下端に寄ります。高さの揃った並びに置いたとき、 **カードごとに本文の長さが違っても操作の位置が揃います。** ### 画像のセクションは持っていません [#画像のセクションは持っていません] 画像はそのまま中身として置けます。**枠の余白いっぱいまで広げる**セクションは用意していません—— 広げ方(縦横比、角の丸め、上端だけか両端か)を決めていないためです。 決めずに口だけ用意すると、**利用側ごとに違う広げ方が生まれます。** ## 外側の要素は `div` 以外にもできます [#外側の要素は-div-以外にもできます] 記事なら `article`、セクションなら `section`、カード全体がリンクなら `a` にできます。 `asChild` を付けると、**Card は要素を1つも作らず**、クラスと属性を子へ移して子だけを描きます。 ```tsx

見出し

本文

``` **面の宣言も hover の宣言もそのまま子へ移ります。** リンクになっても、背景と前景は同時に切り替わります。 子は**要素1つだけ**です。 ### 例: default ```tsx import { Button } from '../../button/button.tsx'; import { Card } from '../card.tsx'; import { CardDescription, CardFooter, CardHeader, CardTitle } from '../card-parts.tsx'; /** * 通常。**面を宣言しているだけで、色は1つも書いていない。** * 背景・文字色・境界色は `data-sg-surface="surface"` から来る。 * * 中のセクションは見出し・補足・本文・操作に分かれる。 * **セクションは面も色も持たない**——面を宣言するのは枠だけである。 * * 影の既定は `none`。影を使わない設計でもそのまま使えるようにしてある。 */ export default function Default() { return ( <> 面は宣言する 背景も文字色も境界色も、面の宣言から来ます。

この枠は背景色を1つも書いていません。

浮かせた面

ライトモードでは影が、ダークモードでは1段深い輪郭が出ます。

); } ``` ### 例: empty ```tsx import { Card } from '../card.tsx'; /** * 空。**中身が無くても面として成立すること。** * * 骨格の余白は画面幅で動くので、空のときの高さも画面幅によって変わる。潰れないことを見る。 */ export default function Empty() { return ; } ``` ### 例: edge ```tsx import { Card } from '../card.tsx'; /** * エッジケース。**面が重なるとき。** * * `overlay` は通常の面と同じ深さに置いてあるので、**影が無いとダークモードで下地と同化する。** * そのため `overlay` を選ぶと影が自動で付く——ここでは `elevation` を書いていない。 * * 面の中に面を置いた場合も見る。**内側の役割は1段深い値を指す。** * 長い語が折り返さずに枠を破らないことも、ここで見る。 */ export default function Edge() { return (

面の中の面

Supercalifragilisticexpialidocious/長い識別子でも枠は破れません。

重なる面。hover すると面が1段深くなります。

); } ``` ### 例: as-child ```tsx import { Card } from '../card.tsx'; /** * **枠が `div` とは限らない。** * * `asChild` を付けると、この枠は要素を1つも作らず、 * クラスと属性を子へ移して子だけを描く。 * * 記事なら `article`、カード全体を押せるようにするなら `a` になる。 * 面の宣言も hover の宣言も、そのまま子へ移る—— * **リンクになっても、背景と前景は同時に切り替わる。** */ export default function AsChild() { return (

記事として組む

見出しと本文を持つ塊なので、`article` で描いています。

カード全体がリンク

hover すると面の文脈が1段深くなります。

); } ``` ### 例: parts ```tsx import { Card } from '../card.tsx'; import { CardDescription, CardFooter, CardHeader, CardTitle } from '../card-parts.tsx'; /** * **セクションの使い分け。** * * 見出しは既定で `h3` になる。カードがページのどこに置かれるかで * 正しい深さは変わるので、`asChild` で差し替えられる。 * * セクションは必須ではない。**要るセクションだけを置く。** * 操作のセクションは下端に寄るので、高さの揃った並びでは位置が揃う。 */ export default function Parts() { return (

見出しの深さを変える

ページ直下に置くなら h2 が正しいこともあります。

本文がここに入ります。

操作は下端に寄ります
本文が短い側 それでも位置は揃います
); } ``` # Carousel (/docs/components/carousel) ## 依存が2つインストールされます [#依存が2つインストールされます] Carousel を追加すると [`embla-carousel-react`](https://www.embla-carousel.com/) と `embla-carousel-autoplay` が 一緒にインストールされます。**Carousel を使うときだけ**です。 スクロールの仕組みは [Embla](https://www.embla-carousel.com/) が持っています。素の `scroll-snap` でもスクロールと吸着はできますが、 **掴んで引く操作**と**端で折り返すスクロール**は自分で書くことになります。 どちらもサイトやアプリで普通に使われるものです。 ## Embla の設定をそのまま渡せます [#embla-の設定をそのまま渡せます] `options` は **Embla の設定をそのまま受けます。** こちらで包み直していないので、 Embla が設定を足せば何もせずに使えます。 ```tsx ``` よく使うもの。 | | | | ---------------- | ---------------------------------- | | `loop` | 端で折り返す。**順番に意味が無いときだけ**(下に条件があります) | | `align` | 吸着の位置(`start` / `center` / `end`) | | `slidesToScroll` | 1回で何枚スクロールするか | | `dragFree` | 吸着せずに慣性で流す | | `startIndex` | 何枚目から始めるか | 全部の一覧は [Embla の設定](https://www.embla-carousel.com/api/options/)にあります。 **`duration` だけ渡せません。** スクロールの速さは**動きを減らす設定(`prefers-reduced-motion`)から決めている**ので、 渡せるようにすると利用側がその設定を打ち消せてしまいます。 尊重するのは利用側の責務ではありません。 ## 前後のボタンと位置の表示は Carousel が描きます [#前後のボタンと位置の表示は-carousel-が描きます] ブラウザが作ってくれる仕組み(`::scroll-button()` と `::scroll-marker`)は **Chromium にしかありません。** 乗せると、他のブラウザで操作手段そのものが消えます。 ## 名前が要ります [#名前が要ります] `label` は必須です。**何のカルーセルかが読み上げに出ない**と、 前後にスクロールしても何を見ているのか分かりません。 ```tsx ``` ## 端では押せません [#端では押せません] **移動先が無いのに押せるのは筋が通りません。** 1枚しか無ければ、前も次も押せません。 ## 折り返しをいつ使うか [#折り返しをいつ使うか] 折り返す形(`options={{ loop: true }}`)は既定ではありません。 | | | | --------- | ------------------------------------------ | | **入れてよい** | 写真・広告・お客様の声のように、**順番に意味が無い**もの。どこから見ても構わない | | **入れない** | 手順・記事の並び・検索結果のように、**順番に意味がある**もの | 折り返すと「最後まで来た」が分からなくなります。 **順番に意味があるものでは、終わりに着いたことが読み取れなくなります。** ## 現在位置はスクリーンリーダーにも伝わります [#現在位置はスクリーンリーダーにも伝わります] 位置表示のボタンには `aria-current` が付きます。 **色の違いだけで示すと、色が見えない利用者や読み上げには届きません。** ## ボタンと位置表示は、スクロールする枠の外に置いてください [#ボタンと位置表示はスクロールする枠の外に置いてください] `CarouselSlides` がスクロールする枠を描きます。**その中に置くと一緒にスクロールしてしまい**、 2枚目へ進んだ瞬間に画面の外へ出ます。見た目には「ボタンが消えた」としか映りません。 ## 自動で動かすときは、停止ボタンが必須です [#自動で動かすときは停止ボタンが必須です] `autoplay` を使うときは `CarouselPlayPause` を一緒に置いてください。 **置かないとエラーになります。** ```tsx … ``` ## 4つのボタンは、それぞれ別の役目です [#4つのボタンはそれぞれ別の役目です] **停止ボタンは、前後のボタンの代わりではありません。** | | 役目 | | ------ | -------------- | | 前へ・次へ | **場所を移す** | | 止める・再生 | **勝手に動くのを制する** | | 印 | **いまどこか**を示す | **同じ場所に置くと片方が消えます。** 停止ボタンを「前へ」の位置に置くと、前へ移動する手段が無くなります。 例からどちらかが欠けたらテストが失敗するようにしてあります。 自動で動くものには**止める手段が無ければなりません**(WCAG 2.2.2)。 文書に書くだけでは守られないので、**組み立ての側で成立しない形**にしています。 **黙って動き続けるほうが害が大きい**——読んでいる最中に勝手に送られ、止める方法がありません。 間隔の既定は 4000ms です。**選んだ値で、段から導いてはいません。**`autoplay={{ delay: 8000 }}` のように渡せます。 触ったとき・ポインタが乗ったとき・フォーカスが入ったときは止まります。 **読んでいる最中に送られるのは邪魔です。** **動きを減らす設定のときは、止まった状態で始まります。** 黙って動かしませんが、動かす手段は残します。 ## 位置の見た目を自分で決めたいときは `useCarouselPosition` [#位置の見た目を自分で決めたいときは-usecarouselposition] サムネイルの列や「3 / 7」のような表示は、このフックで組めます。渡されるのは3つだけです。 | | | | ------------- | ---------------- | | `selected` | いま見えている枚目(0 始まり) | | `count` | 何枚あるか | | `goTo(index)` | その枚へ飛ぶ | ```tsx function Thumbnails({ photos }: { photos: { src: string; alt: string }[] }) { const { selected, count, goTo } = useCarouselPosition(); // 1枚しかないなら出しません。押しても何も起きないものを置かないためです if (count <= 1) return null; return (
    {photos.map((photo, index) => (
  • ))}
); } ``` **いまどこを見ているかは、見た目だけでなく属性でも伝えてください**(`aria-current`)。枠線の色だけで表すと、見えない人に届きません。 **送りの仕組みそのものは渡していません。** 渡すと、その仕組みの API がそのまま約束になり、入れ替えたときにあなたのコードが壊れます。前後のボタンと位置の印で足りるなら、`CarouselPrevious` / `CarouselNext` / `CarouselMarkers` を使ってください。 ## 動きを減らす設定ではアニメーションなしで切り替わります [#動きを減らす設定ではアニメーションなしで切り替わります] スクロールのアニメーションは止まり、すぐに切り替わります。**利用側で何かする必要はありません。** ### 例: default ```tsx import { Card } from '../../card/card.tsx'; import { Carousel, CarouselMarkers, CarouselNext, CarouselPrevious, CarouselSlide, CarouselSlides, } from '../carousel.tsx'; /** * 通常。**1枚ずつスクロールする。** * * 前後のボタンは端で押せなくなる。印は押すとそこへ飛ぶ。 * **掴んで引く操作も効く**——スクロールの仕組みは Embla が持っている。 */ export default function Default() { return ( {['一', '二', '三', '四'].map((n) => ( {n}枚目の中身 ))}
); } ``` ### 例: empty ```tsx import { Carousel, CarouselMarkers, CarouselSlides } from '../carousel.tsx'; /** * 空。**1枚も無いとき。** * * 印は出ない——**数はスライドから来る**ので、無ければ0個になる。 * 枠だけが残り、潰れない。 */ export default function Empty() { return ( ); } ``` ### 例: edge ```tsx import { Card } from '../../card/card.tsx'; import { Carousel, CarouselMarkers, CarouselNext, CarouselPlayPause, CarouselPrevious, CarouselSlide, CarouselSlides, } from '../carousel.tsx'; /** * エッジケース。**1枚だけのときと、端で折り返すとき。** * * 1枚しか無ければ前も次も押せない。**移動先が無いのに押せるのは筋が通らない。** * * 折り返す形は既定ではない。**順番に意味が無いときだけ入れる**—— * 写真や広告のように、どこから見ても構わないもの。 * **手順や記事の並びには入れない。** 折り返すと「最後まで来た」が分からなくなる。 * * 自動でスクロールさせるときは**停止ボタンが必須**である。置かないと落ちる—— * 自動で動くものには止める手段が要る。 * 動きを減らす設定のときは**止まった状態で始まる。** * * **停止ボタンは、前後のボタンの代わりではない。** 4つとも別の役目を持つので、 * 同じ場所に置くと片方が消える。 */ export default function Edge() { return (
これだけ
{['一', '二', '三'].map((n) => ( {n}枚目 ))} {/* **停止ボタンは、前後のボタンの代わりではない。** 4つとも別の役目を持つ。 前へ・次へは**場所を移す**もの、止める・再生は**勝手に動くのを制する**もの、 印は**いまどこか**を示すもの。 同じ場所に置くと、片方が消える。 */}
{['一', '二', '三'].map((n) => ( {n}枚目 ))}
); } ``` # Checkbox (/docs/components/checkbox) ## 素の input に乗っています [#素の-input-に乗っています] 読み上げ・キーボード・フォームへの載り方は、**どれもブラウザが持っています。** 差し替えているのは見た目だけです。 入った印は兄弟の要素に置いて重ねています(`input` の中に要素は置けません)。 出し入れは CSS の `peer-checked:` なので、**このコンポーネントは状態を持ちません。** 持たせると、値の出どころが `input` とこのコンポーネントの2つになります。 いまは `input` だけが値を持つので、素のフォームでも `react-hook-form` の `register()` でも、そのまま載ります。 ## ラベルは Field が付けます [#ラベルは-field-が付けます] **ラベルを自分では持っていません。** 持つと結びつけの経路が2つになり、 片方だけ直したときにずれます。 ラベルが右に来る配置は Field が持っています。 ```tsx ``` ## チェックの印は塗りを宣言しています [#チェックの印は塗りを宣言しています] `bg-accent` のような**塗るだけの道はありません**。塗りは宣言するもので、 宣言すると背景と前景が対で決まります——中の印は文字色を継ぐので、読める側になります。 押せないときだけ段が落ちます。宣言は CSS では差し替えられませんが、 `disabled` は**描くときに分かる**ので、そこで分けています。 ### 例: default ```tsx import { Field } from '../../field/field.tsx'; import { Checkbox } from '../checkbox.tsx'; /** * 通常。**ラベルは Field が付ける。** * * `layout="inline"` でラベルが右に来る。上に置くと、 * ラベルがどの選択肢のものか目で辿れない。 */ export default function Default() { return (
); } ``` ### 例: empty ```tsx import { Checkbox } from '../checkbox.tsx'; /** * 空。**ラベルも説明も無い。** * * 表の行を選ぶような、**ラベルを置く場所が無い**場面がある。 * そのときは `aria-label` で名前を渡す——名前が無いと読み上げが「チェックボックス」としか言わない。 */ export default function Empty() { return ; } ``` ### 例: edge ```tsx import { Field } from '../../field/field.tsx'; import { Checkbox } from '../checkbox.tsx'; /** * エッジケース。**押せないときと、誤りのとき。** * * 押せないときは塗りの段が落ちる。`data-sg-fill` は宣言なので CSS では * 差し替えられないが、**`disabled` は描くときに分かる。** * * 誤りのときは線が変わる。**線だけで伝えない**——文言も一緒に出す。 */ export default function Edge() { return (
); } ``` # Field (/docs/components/field) ## ラベルと入力の関連付けは Field が作ります [#ラベルと入力の関連付けは-field-が作ります] ラベルと入力、説明と入力、誤りと入力。**どれも忘れると読み上げだけが黙ります。** 見た目は正常なので、書き忘れても気づけません。 `Field` に `id` を1つ渡せば、ラベル・説明・誤りの関連付けは全部こちらで作ります。 ```tsx ``` **利用側が渡すのは `id` を1つだけ**です。説明と誤りの `id` はそこから導きます。 ## `id` は必須です [#id-は必須です] 自動で作る道は取っていません。安定した名前を作るには `useId` が要り、 **このコンポーネントがクライアント側になる**ためです。 必須にしたことで、**書き忘れが型で止まります。** ## バリデーションは持ちません [#バリデーションは持ちません] **規則を走らせる仕組みはありません。** 誤りの文言を受け取るだけです。 受け取るのは標準の props だけなので、**どのライブラリとも組めます**—— [react-hook-form](https://react-hook-form.com/) でも [TanStack Form](https://tanstack.com/form) でも [Conform](https://conform.guide/) でも、素の `FormData` でも同じです。 ```tsx {/* react-hook-form の例 */} ``` ### 実際に動かせます [#実際に動かせます] 下は **react-hook-form で検証が走る本物のフォーム**です。 打っている最中に検証が走ります——形が整うと線が緑に変わり、印が出ます。 **`Field` は `react-hook-form` に依存していません。** このデモが使っているだけで、 受け取るのは標準の props だけです。`register()` が返すものをそのまま渡せます。 ### 入力側で同じ属性を書くとエラーになります [#入力側で同じ属性を書くとエラーになります] `id` / `aria-describedby` / `aria-invalid` は **Field が渡します。** 入力側で書くと**Field が渡した値が上書きされ、関連付けだけが切れます**—— 見た目は正常なままなので気づけません。だから**エラーにしています。** `register()` が返す `name` / `onChange` / `onBlur` / `ref` は重なりません。 ## 入力が正しいことも示せます [#入力が正しいことも示せます] ```tsx ``` 境界が変わり、**入力の右端に印**が出ます。印は読み上げから隠れています—— 出しても「チェック」としか言わないので、**状態は境界が伝えます。** 誤りと同時には使えません。**両方は成り立たない**ので、誤りが勝ちます。 ## 空の段落は置きません [#空の段落は置きません] 説明も誤りも無いときは、**何も描きません。** 置くと、 何も書かれていない行が読み上げに出ます。 ## 必須であることを記号だけで伝えません [#必須であることを記号だけで伝えません] `*` は読み上げに届かないので、**文字も一緒に置いています。** ### 例: default ```tsx import { Input } from '../../input/input.tsx'; import { Field } from '../field.tsx'; /** * 通常。**結びつけを利用側に書かせない。** * * ラベルと入力、説明と入力。どれも忘れると読み上げだけが黙る—— * 見た目は正常なので、書き忘れても気づけない。 * * 利用側が渡すのは `id` を1つだけである。 */ export default function Default() { return (
); } ``` ### 例: empty ```tsx import { Input } from '../../input/input.tsx'; import { Field } from '../field.tsx'; /** * 空。**説明も誤りも無いとき。** * * ラベルと入力だけが残る。**空の段落を置かない**—— * 置くと、何も書かれていない行が読み上げに出る。 */ export default function Empty() { return (
); } ``` ### 例: edge ```tsx import { Input } from '../../input/input.tsx'; import { Textarea } from '../../textarea/textarea.tsx'; import { Field } from '../field.tsx'; /** * エッジケース。**誤りと説明が両方あるとき、必須のとき。** * * 両方あるときは**両方が読み上げに届く**——片方だけにすると、 * もう片方が黙る。 * * 必須の印は記号だけでは読み上げに届かないので、**文字も一緒に置く。** * * 満たしていることも表せる。**誤りとは別の仕組みで受け取る**—— * 誤りには `aria-invalid` という標準の属性があるが、 * 満たしていることを表す属性は無い。 * * 規則は持たない。**誤りの文言を受け取るだけ**である—— * 走らせるのは利用側で、どのライブラリとも組める。 */ export default function Edge() { return (