メインコンテンツまでスキップ

Styling

tremolo-ui は CSS を配りません。 コンポーネントが受け持つのはマークアップの描画と、 値の管理と、ポインタ・キーボード・ホイールの処理までで、見た目は利用者のものです。 素の CSS でも CSS Modules でも Tailwind でも CSS-in-JS でも、アプリの他の部分で 使っているやり方をそのまま使えます。

🎨クラスは自分で当てる​

クラス名は配りません。 各パートが className を受け取り、そこに渡したものが その要素の持つ唯一のクラスになります。こちらのクラスを打ち消す必要も、 こちらのスタイルが勝手に当たることもありません。

<Slider.Root className={theme.root} value={value} min={0} max={100} onChange={setValue}>
<Slider.Track className={theme.track}>
<Slider.Thumb className={theme.thumb} aria-label="Level" />
</Slider.Track>
</Slider.Root>

style も同じです。複数の要素を描くパートは、中身用に classes を受け取ります (Slider.MarksOption の classes={{ mark, label }} など)。Piano の鍵盤は keyProps から届きます。

下のテーマが CSS Modules なのはこのためで、各ファイルがパートごとに 1 クラスを export します。上のように結びつけて使ってください。

🚦状態​

状態はクラスを足す形ではなく、要素の属性として乗っています。セレクタ 1 つで届きます。 off の状態では属性そのものが付かないので、属性名だけがセレクタになります(="false" に 誤ってマッチすることがありません)。

各パートが持つ属性は、それぞれのページの API リファレンス に 載せています(Knob、 Slider、 XYPad、 NumberInput、 Piano、 PointsEditor)。よく使うのは [data-disabled] と [data-readonly] です。

それ自体がコントロールである要素(Knob、NumberInput.InputField、ステッパー、 thumb の中の range input)には対応する ARIA(aria-disabled / aria-readonly)も 付いています。ARIA はコントロールが何であるかを伝えるもので、スタイルが読むのは data- 属性です。

.slider[data-disabled] {
opacity: 0.5;
}

フォーカスを受けるのは実際の要素なので、:focus / :hover / :focus-within も どこでもと同じように使えます。

📐カスタムプロパティ​

CSS の宣言 1 つで済む見た目はクラスや style で直接指定します。同じ値を複数の 宣言で使う場合、一部の props はカスタムプロパティに値を書き込みます。

<Slider.Track thickness={16} />
// 以下と同じ
<Slider.Track style={{ '--thickness': '16px' }} />

これらの prop では、スタイルシートや inline style でも同じ値を指定でき、通常の cascade で優先順位が決まります。既定値はコンポーネントではなく CSS にあります。 そのため、すべての instance へ prop を渡さなくても CSS ルールで変更できます。

.track {
--thickness: 16px;
}

propへ渡した数値はpxとして扱われます。それ以外はそのまま書き込まれるため、 '3rem'、'100%'、'auto' も使用できます。

どのパートの style にも、キャスト無しでカスタムプロパティを書けます。 React の CSSProperties には -- で始まるキーが無いので、本来 TypeScript は style={{ '--thickness': '16px' }} を弾きます。パートはこれを CSSVariables で広げて いて、そのパートが書き込む・読むプロパティはエディタが補完し、それ以外の -- で始まる 名前(テーマ独自のもの)もそのまま受け取ります。

どのパートがどのプロパティを持つかは、それぞれのページの API リファレンス に 載せています(Knob、 Slider、 XYPad、 NumberInput、 Piano、 PointsEditor)。

テーマはその上に自分の変数を足します。下のテーマには --thumb-size や track の --axis があります。これらはテーマが読むためにあるもので、名前を決めるのも テーマの側です。

📋このサイトで使っているテーマ​

ドキュメント中の例はすべて以下のファイルでスタイルを当てています。依存先ではなく 出発点なので、必要なものをプロジェクトにコピーして書き換えてください。

いずれもビルド不要の素の CSS です。--knob-size や --thumb-size のように 複数箇所で使う値はカスタムプロパティにまとめ、1 箇所だけの大きさや色は 通常の CSS 宣言で指定しています。

テーマが決めるのは見た目であって、位置ではありません。 各パートの配置は コンポーネント自身が行います(thumb は自分で flow から外れ、値の位置に中央を 合わせます)。そのため、自前のテーマで規則を書き忘れてパートが変な場所に飛ぶ、 ということは起きません。配置に好みの余地がある部分はカスタムプロパティで開けて あり、thumb・mark・point の --translate で中央合わせをずらせます。

Knob.module.css
/*
* The demo theme for `Knob`, as a CSS Module: one class per part, applied by
* the caller. Copy this file into your project and edit it.
*
* The knob draws itself as an SVG, so this is colour and cursor only.
*/

.root {
/* The knob has no intrinsic size: the SVG inside scales to its box.
Override with the `size` prop, or by setting this variable. */
--knob-size: 50px;

display: inline-block;
width: var(--knob-size);
height: var(--knob-size);
/* A knob is a fixed-size control, like a button. Without this a flex row
that runs short shrinks the width and leaves the height alone, so the box
stops being square and the knob sits in it with dead space below.
`aspect-ratio` is not the answer: it is ignored while both width and
height are definite, and dropping the height to free it up lets
`align-items: stretch` pull the box the other way. */
flex-shrink: 0;
cursor: grab;
outline: 0;

&[data-readonly] {
cursor: not-allowed;
}

&[data-dragging] {
cursor: inherit;
}
}

.activeLine {
color: oklch(69.2% 0.17548 266.373);

:where(.root:focus, .root:hover) & {
color: oklch(59.5% 0.17548 266.373);
}

:where(.root[data-disabled]) & {
color: oklch(50.5% 0.03307 270.021);
}
}

.inactiveLine {
color: oklch(95% 0 0);

:where(.root:focus, .root:hover) & {
color: oklch(90% 0 0);
}
}

.thumb {
color: oklch(85.7% 0 0);

:where(.root:focus, .root:hover) & {
color: oklch(84.5% 0 0);
}
}

.thumbLine {
color: oklch(94.2% 0 0);
}

/*
* Dark mode follows whatever marks the page — `.dark` and `[data-theme=dark]`
* are what this site and Storybook set.
*/
:global(.dark) .activeLine,
:global([data-theme='dark']) .activeLine {
color: oklch(59.5% 0.17548 266.373);

:where(.root:focus, .root:hover) & {
color: oklch(65.2% 0.17548 266.373);
}
}

:global(.dark) .inactiveLine,
:global([data-theme='dark']) .inactiveLine {
color: oklch(41.7% 0 0);

:where(.root:focus, .root:hover) & {
color: oklch(44.59% 0 0);
}
}

:global(.dark) .thumb,
:global([data-theme='dark']) .thumb {
color: oklch(48.6% 0 0);

:where(.root:focus, .root:hover) & {
color: oklch(51.7% 0 0);
}
}

:global(.dark) .thumbLine,
:global([data-theme='dark']) .thumbLine {
color: oklch(84.2% 0 0);
}

ダークモード​

テーマはページ側の目印を読みます。祖先の .dark、または [data-theme="dark"] で、このサイトと Storybook が付けているものです。

:global(.dark) .thumb,
:global([data-theme='dark']) .thumb {
--color: oklch(64.5% 0.17548 266.373);
}

セレクタが 2 つ並んでいるのは、CSS Module では :global が書いた場所に そのまま残るためです。自分のアプリでのダークの表し方(独自のクラス、あるいは @media (prefers-color-scheme: dark))に差し替えてください。

🧩すでに使っているやり方で​

クラスはクラスです。下は同じノブを 2 通りで書いたもので、片方は CSS Modules、 もう片方は Tailwind です。ライブラリ側に特別扱いはありません。各パートが className を受け取り、あとは自分のスタイルシートの話です。

Loading...
import { useState } from 'react'

import { Knob } from '@tremolo-ui/react'

import myKnob from './my-knob.module.css'

function App() {
  const [value, setValue] = useState(64)

  return (
    <div className={myKnob.container}>
      <Knob.Root
        aria-label="Level"
        className={myKnob.knob}
        value={value}
        min={0}
        max={100}
        size={50}
        onChange={(v) => setValue(v)}
      >
        <Knob.SVGRoot>
          <Knob.InactiveLine className={myKnob.inactiveLine} />
          <Knob.ActiveLine className={myKnob.activeLine} />
          <Knob.Thumb
            className={myKnob.thumb}
            classes={{ line: myKnob.thumbLine }}
          />
        </Knob.SVGRoot>
      </Knob.Root>
      {value}
    </div>
  )
}

export default App

🧭次のステップ​

tremolo-ui が提供するコンポーネントを見てみましょう。
Components - Knob