sashigane
コンポーネント

Button

ボタン。押して操作を実行する。リンクにも差し替えられる

押している最中の見た目は持ちません

押している最中だけ見た目を変える仕組みは入れていません。hover と focus で足りると判断しています。

必要になった場合は、面の深さで表すことになります——ただし一番深い面には行き先が無いので、 そこは設計として解く必要があります。いまは持っていません。

無効のときは面を暗くします

不透明度は使いません。薄めると前景と背景が同時に下地へ寄り、読みやすさの保証が効かなくなる ためです。

代わりに、無効のときだけ凹んだ面を宣言して文字を淡くします。 面の仕組みに乗るので、塗り・淡い塗り・枠・文字だけのどれでも同じ形で暗くなります。 塗りが残って「押せそうに見えて押せない」状態になりません。

hover は4種すべてで変わります

押せることが見た目から分かる必要があるので、塗り方によらず hover で背景が変わります。

塗り方hover でどうなるか
solid塗りが1段深くなる
subtle色味を保ったまま1段深くなる
outline / ghost面が1段深くなる(塗りが無いので、面の側で出す)

subtle だけは自分で塗っているため、面の hover の中立色に上書きされないよう 自分の塗りを優先しています。上書きされると、hover した瞬間に色味が消えて灰色になります。

塗りは宣言します

塗りのボタンは色を1つも書きません。 面と同じで、宣言が背景・境界・文字を同時に与えます。

<Button>押す</Button>          {/* data-sg-fill="accent" が付く */}
<Button tone="danger">消す</Button>

塗りの段はテーマでも面でも変わりません。 ブランドの色はテーマで変わるものではない、 という線引きです。文字は明るい端に揃い、ライトモードでもダークモードでも同じ見え方になります。

コンポーネントの識別は境界が担います。 深い面の上では塗りだけでは地から離れないので、 境界を面ごとに変えています。hover では中だけが濃くなり、境界は動きません—— だから hover 中もコンポーネントの輪郭は保たれます。

色は1つも書いていません

塗りの色も、その上に載る文字の色も、hover でずらす先も、すべてトークンが持っています。 トーンは塗りを持つものすべて(accent / danger / warning / success / info)に出します。

枠の色は中立です

outline の境界はトーンの色になりません。色はトーンに、境界は面の骨格に属するという 線引きです。揃え忘れではありません。

アイコン

アイコンは持っていません。利用側が好きなものを子として置きます。 文字と並べるときは枠が間を空けるので、そのまま入れれば揃います。

<Button>
  <PlusIcon aria-hidden="true" />
  追加する
</Button>

文字と並べるアイコンは読み上げから隠します。 文字が既に同じことを言っているので、 隠さないと二重に読まれます。

アイコンだけのボタンには名前が要ります

目に見える文字が無いので、読み上げに渡すものがありません。 aria-label か aria-labelledby を型で必須にしてあります。

<Button iconOnly aria-label="閉じる">
  <CloseIcon aria-hidden="true" />
</Button>

書き忘れてもエラーは出ず、見た目も正常です。読み上げが「ボタン」としか言わなくなるだけなので、 型で塞ぐしかありません。

アイコンの大きさは行の高さに合わせます

iconOnly は左右の余白を詰めるだけで、上下の余白は文字のときと同じです。 つまり中身の高さが、そのまま枠の高さの差になります。

行の高さ(size-6 = 24px)に合わせると、枠は正方形になり、文字のボタンとも高さが揃います。 小さいアイコンを入れると枠も小さくなります。

アイコンのサイズは属性ではなくクラスで書いてください。 width="24" と書くと、 スケールの外の値を書けてしまいます。

正方形に固定していません。 文字のボタンの高さはスケールの段の上に乗っていないので、 縦横を段で指定すると横に並べたときにかえって揃わなくなります。

ローディング中

loading を付けるとスピナーが出て、押せなくなります。

<Button loading>送信中</Button>

暗くはしません

無効は「できない」で、ローディング中は「いま起きている」です。 同じ見た目にすると2つの状態が区別できないので、ローディング中は塗りをそのまま残します。

暗くなるのは無効のときだけです。

文字は残ります

スピナーだけにすると、何を待っているのか分からなくなります。

ただしアイコンだけのボタンではスピナーが置き換えます——並べると枠が横に伸びて、 正方形でなくなるためです。

枠はスピナーのぶんだけ横に伸びます

文字を残すので、押した瞬間にボタンが広がり、隣のものが動きます。 ずれてほしくない場所では、枠の幅を先に決めておいてください。

アイコンだけのボタンではスピナーが置き換えるので伸びません。

無効と両方渡すと、ローディング中が勝ちます

disabled={!valid || submitting} と loading={submitting} を並べて書く形が 普通にあるので、型では塞いでいません。

暗くなった上でスピナーが回ると「できないのに待っている」になり、意味が通りません。 ローディング中の方が短く、いま起きていることなので、そちらを見せます。 押せないことは変わりません。

押せなくなります

スピナーが回っているだけで押せると、二重に送れてしまいます。 aria-busy も付きます。

スピナーは読み上げから隠れています。ボタンは文字か aria-label で既に名前を持っているので、 隠さないと二重に読まれます。

loading と asChild は同時に使えません

disabled と同じ理由です。押せなくする手段がリンクにはありません。

リンクをボタンの見た目にできます

見た目はボタン、実体はリンクという形はよくあります。 asChild を付けると、Button は要素を1つも作らず、クラスと属性を子へ移して子だけを描きます。

<Button asChild>
  <a href="/docs">ドキュメントへ</a>
</Button>

描かれるのは <a> 1つです。button の中に a が入る形にはなりません。

子に渡せるのは操作できる要素1つだけです。複数の中身をまとめるときは、その要素の内側に入れてください。 外側を div で包むと、押せない div がボタンの見た目になります。

{/* こう書く */}
<Button asChild>
  <a href="/x">
    <span aria-hidden="true">↗</span>
    <span>外部リンク</span>
  </a>
</Button>

asChild と disabled は同時に使えません

型で禁止してあります。押せない状態は button 要素の機能で、リンクにはありません—— disabled を a に付けてもエラーは出ず、何も起きません。 暗い見た目のまま押せてしまうので、「押せそうに見えて押せない」の逆という、 より悪い状態になります。

押せない状態が要るなら button のままにしてください。 リンクを押せなくしたいなら、リンクを出さないのが正しい形です。

3つの状態

通常・空・エッジケースの3つは、どのコンポーネントにも必ずあります。

通常default.tsx

通常。塗り方4種とトーン5つの組み合わせ。

空empty.tsx

空。ラベルが無くても枠として成立すること。

エッジケースedge.tsx

エッジケース。無効と、長いラベル。

使い方

3つの状態とは別に、説明のために置いている例です。

要素の差し替えas-child.tsx

見た目はボタン、中身はリンク。

アイコンicon.tsx

アイコンと一緒に使う。

← 行の高さより小さいと、枠も小さくなります

読み込み中loading.tsx

ローディング中。

← 暗くなるのは「できない」ときだけです

props

Prop

Type

型と説明はコンポーネントのソースから抽出しています。既定値は出していません——cva の defaultVariants は 署名に現れないので、表に出すと「引数のデフォルトが書かれているものだけ」になり、嘘になります。

On this page