Styling
tremolo-ui は CSS を配りません。 コンポーネントが受け持つのはマークアップの描画と、 値の管理と、ポインタ・キーボード・ホイールの処理までで、見た目は利用者のものです。 素の CSS でも CSS Modules でも Tailwind でも CSS-in-JS でも、アプリの他の部分で 使っているやり方をそのまま使えます。
クラス名
すべての要素に tremolo- で始まるクラスが付いているので、自前のラッパーを
挟まなくても素の CSS で届きます。
.tremolo-knob-active-line {
color: rebeccapurple;
}
名前はコンポーネントとパートに対応します。tremolo-slider、
tremolo-slider-track、tremolo-slider-thumb、tremolo-slider-marks のように。
すべては下のテーマに載っています。
状態
状態はクラスを足す形ではなく、要素の属性として乗っています。セレクタ 1 つで届きます。
| 属性 | 対象 | 意味 |
|---|---|---|
[aria-disabled="true"] | 全コンポーネント | disabled が指定されている |
[aria-readonly="true"] | 全コンポーネント | readonly が指定されている |
[data-dragging="true"] | Knob / PointsEditor.Point | ドラッグ中 |
[data-vertical="true"] | Slider / Slider.Track / Slider.MarksOption | 縦向きのスライダー |
[data-active="true"] | Piano の鍵盤 | その音が鳴っている |
[data-out-of-range="true"] | NumberInput.InputField | 値が min / max の外にある |
.tremolo-slider[aria-disabled='true'] {
opacity: 0.5;
}
フォーカスを受けるのは実際の要素なので、:focus / :hover / :focus-within も
どこでもと同じように使えます。
このサイトで使っているテーマ
ドキュメント中の例はすべて以下のファイルでスタイルを当てています。依存先ではなく 出発点なので、必要なものをプロジェクトにコピーして書き換えてください。
いずれもビルド不要の素の CSS で、カスタムプロパティ(--knob-size、
--thumb-size、--active-color など)を使っているため、よくある変更は
1 行の上書きで済みます。
- Knob.css
- Slider.css
- XYPad.css
- NumberInput.css
- Piano.css
- PointsEditor.css
.tremolo-knob {
/* 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;
&[aria-readonly='true'] {
cursor: not-allowed;
}
&[data-dragging='true'] {
cursor: inherit;
}
}
.tremolo-knob-active-line {
color: oklch(69.2% 0.17548 266.373);
:where(.tremolo-knob:focus, .tremolo-knob:hover) & {
color: oklch(59.5% 0.17548 266.373);
}
:where(.tremolo-knob[aria-disabled='true']) & {
color: oklch(50.5% 0.03307 270.021);
}
}
.tremolo-knob-inactive-line {
color: oklch(95% 0 0);
:where(.tremolo-knob:focus, .tremolo-knob:hover) & {
color: oklch(90% 0 0);
}
}
.tremolo-knob-thumb {
color: oklch(85.7% 0 0);
:where(.tremolo-knob:focus, .tremolo-knob:hover) & {
color: oklch(84.5% 0 0);
}
}
.tremolo-knob-thumb-line {
color: oklch(94.2% 0 0);
}
:where(.dark, [data-theme='dark']) {
.tremolo-knob-active-line {
color: oklch(59.5% 0.17548 266.373);
:where(.tremolo-knob:focus, .tremolo-knob:hover) & {
color: oklch(65.2% 0.17548 266.373);
}
}
.tremolo-knob-inactive-line {
color: oklch(41.7% 0 0);
:where(.tremolo-knob:focus, .tremolo-knob:hover) & {
color: oklch(44.59% 0 0);
}
}
.tremolo-knob-thumb {
color: oklch(48.6% 0 0);
:where(.tremolo-knob:focus, .tremolo-knob:hover) & {
color: oklch(51.7% 0 0);
}
}
.tremolo-knob-thumb-line {
color: oklch(84.2% 0 0);
}
}
.tremolo-slider {
/* Half the thumb size. The thumb overhangs the ends of the track, so the
slider reserves room for it. Override this when using a different size. */
--thumb-size: 22px;
display: inline-flex;
flex-direction: column;
margin: calc(var(--thumb-size) / 2);
cursor: pointer;
outline: 0;
-webkit-tap-highlight-color: transparent;
&[data-vertical='true'] {
flex-direction: row;
}
&[aria-readonly='true'] {
cursor: not-allowed;
}
}
.tremolo-slider-thumb-wrapper {
position: absolute;
translate: -50% -50%;
z-index: 100;
}
.tremolo-slider-thumb {
--color: oklch(59.5% 0.17548 266.373);
width: var(--thumb-size);
height: var(--thumb-size);
background: var(--color);
border-radius: 50%;
outline: none;
&[aria-disabled='true'] {
background: oklch(50.462% 0.03307 270.021);
}
&[aria-readonly='false']:focus {
box-shadow: 0px 0px 0px 3px
color-mix(in srgb, var(--color) 20%, transparent);
}
}
.tremolo-slider-track {
--active: oklch(69.212% 0.12903 267.633);
--inactive: oklch(95% 0 0);
--length: 140px;
--thickness: 10px;
/* Which way the fill runs. `--percent` is where the thumb sits on screen,
so a flipped slider only swaps which side of it is the filled one. */
--axis: to right;
position: relative;
width: var(--length);
height: var(--thickness);
border-radius: calc(var(--thickness) / 2);
/* The component sets `--percent` and paints nothing itself, so the fill is
yours to change: a second colour stop, a gradient, an image. */
background: linear-gradient(
var(--axis),
var(--active) var(--percent),
var(--inactive) var(--percent)
);
&[data-flipped='true'] {
background: linear-gradient(
var(--axis),
var(--inactive) var(--percent),
var(--active) var(--percent)
);
}
&[data-vertical='false'] {
min-width: 50px;
}
&[data-vertical='true'] {
--axis: to bottom;
width: var(--thickness);
height: var(--length);
min-height: 50px;
}
&:hover {
--active: oklch(64.276% 0.15244 266.932);
--inactive: #e0e0e0;
}
&[aria-disabled='true'] {
--active: oklch(75.327% 0.01237 264.547);
--inactive: oklch(89.755% 0.0001 271.152);
}
}
.tremolo-slider-marks {
/* Space between the marks and the track. */
--gap: 6px;
display: block;
position: relative;
margin-top: var(--gap);
&[data-vertical='true'] {
margin-top: 0;
margin-left: var(--gap);
}
}
.tremolo-slider-marks-option {
/* Read by the mark and the label below, which is why they are set here. */
--thickness: 1px;
--length: 0.5rem;
--gap: 2px;
--label-width: auto;
display: flex;
color: oklch(25.197% 0.00003 271.152);
flex-direction: column;
justify-content: center;
align-items: center;
position: absolute;
translate: -50% 0;
z-index: 10;
&[data-vertical='true'] {
flex-direction: row;
translate: 0 -50%;
}
}
.tremolo-slider-marks-option-mark {
background-color: currentColor;
width: var(--thickness);
height: var(--length);
margin-bottom: var(--gap);
&[data-vertical='true'] {
width: var(--length);
height: var(--thickness);
margin-bottom: 0;
margin-right: var(--gap);
}
}
.tremolo-slider-marks-option-label {
text-align: right;
width: var(--label-width);
}
:where(.dark, [data-theme='dark']) {
.tremolo-slider-thumb {
--color: oklch(64.5% 0.17548 266.373);
&:hover {
--color: oklch(67.5% 0.17548 266.373);
}
}
.tremolo-slider-track {
--active: oklch(59.5% 0.17548 266.373);
--inactive: oklch(41.7% 0 0);
&:hover {
--active: oklch(62.5% 0.17548 266.373);
--inactive: oklch(44.7% 0 0);
}
&[aria-disabled='true'] {
--active: oklch(75.327% 0.01237 264.547);
--inactive: oklch(89.755% 0.0001 271.152);
}
}
}
.tremolo-xy-pad {
/* Half the thumb size. The thumb overhangs the edges of the area, so the
pad reserves room for it. Override this when using a different size. */
--thumb-size: 22px;
display: inline-block;
margin: calc(var(--thumb-size) / 2);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&[aria-readonly='true'] {
cursor: not-allowed;
}
}
.tremolo-xy-pad-area {
--color: oklch(95% 0 0);
--width: 120px;
--height: 120px;
/* Room for the thumb to travel. The area only holds its size while
`--width` and `--height` are lengths of their own: give it a percentage,
or put the pad in a flex row that overrides the automatic minimum size,
and it shrinks with whatever is around it — down to nothing, with the
thumb left sitting on a line. */
--min-width: calc(var(--thumb-size) * 2);
--min-height: calc(var(--thumb-size) * 2);
position: relative;
width: var(--width);
height: var(--height);
min-width: var(--min-width);
min-height: var(--min-height);
background-color: var(--color);
}
.tremolo-xy-pad-thumb-wrapper {
position: absolute;
translate: -50% -50%;
z-index: 100;
}
.tremolo-xy-pad-thumb {
--color: oklch(59.5% 0.17548 266.373);
width: var(--thumb-size);
height: var(--thumb-size);
border-radius: 50%;
outline: none;
background-color: var(--color);
&[aria-disabled='true'] {
background-color: oklch(50.462% 0.03307 270.021);
}
&[aria-readonly='false']:focus {
box-shadow: 0px 0px 0px 3px
color-mix(in srgb, var(--color) 20%, transparent);
}
}
:where(.dark, [data-theme='dark']) {
.tremolo-xy-pad-area {
--color: oklch(41.7% 0 0);
}
.tremolo-xy-pad-thumb {
--color: oklch(64.5% 0.17548 266.373);
&:hover {
--color: oklch(67.5% 0.17548 266.373);
}
}
}
.tremolo-number-input {
--border-radius: 4px;
--stepper-width: 20px;
--stepper-icon-size: 12px;
--active-color: oklch(59.5% 0.17548 266.373);
--border-color: oklch(81% 0 0);
/* Room for the padding, the stepper, and a few digits. A flex row that runs
short shrinks its items, and the field inside is sized from the box it
sits in — without a floor the input keeps shrinking until nothing but the
padding is left and there is nowhere to type. */
--min-width: 90px;
display: inline-flex;
flex-direction: row;
position: relative;
width: 140px;
min-width: var(--min-width);
height: min-content;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
outline: none;
transition: all 0.1s;
&:hover {
--border-color: oklch(65.335% 0 0);
}
&:focus-within {
--border-color: var(--active-color);
box-shadow: 0px 0px 0px 2px
color-mix(in srgb, var(--active-color) 10%, transparent);
}
&:not(:focus-within):has(
.tremolo-number-input-field[data-out-of-range='true']
) {
border-color: oklch(64.245% 0.21266 25.499);
}
/* Only leave room for the stepper when one was composed in. */
&:not(:has(.tremolo-number-input-stepper)) {
--stepper-width: 0px;
}
}
.tremolo-number-input-field {
display: block;
color: inherit;
width: calc(100% - 20px - var(--stepper-width));
background-color: inherit;
font: inherit;
padding: 6px 10px;
margin: 0;
border: none;
outline: none;
appearance: none;
&[aria-readonly='true'] {
cursor: not-allowed;
}
}
.tremolo-number-input-stepper {
display: flex;
flex-direction: column;
height: 100%;
position: absolute;
top: 0;
right: 0;
width: var(--stepper-width);
transition: all 0.1s linear;
/* Kept out of the way until the input is being used. */
opacity: 0;
transform: translateX(40%);
}
:is(.tremolo-number-input:hover, .tremolo-number-input:focus-within)
.tremolo-number-input-stepper {
opacity: 1;
transform: translateX(0);
}
.tremolo-number-input-stepper-icon {
width: var(--stepper-icon-size);
height: var(--stepper-icon-size);
}
:is(
.tremolo-number-input-increment-stepper,
.tremolo-number-input-decrement-stepper
) {
border-inline-start: 1px solid oklch(84.522% 0 0);
font-size: 0.75rem;
display: flex;
justify-content: center;
align-items: center;
flex: 1 1 0%;
max-height: 50%;
user-select: none;
cursor: pointer;
&[aria-disabled='false']:active {
background-color: oklch(37.798% 0.03812 254.612 / 0.055);
}
&[aria-disabled='true'],
&[aria-readonly='true'] {
cursor: not-allowed;
}
}
.tremolo-number-input-increment-stepper {
border-top-right-radius: var(--border-radius);
}
.tremolo-number-input-decrement-stepper {
border-top: 1px solid oklch(84.522% 0 0);
border-bottom-right-radius: var(--border-radius);
}
:where(.dark, [data-theme='dark']) {
.tremolo-number-input {
--border-color: oklch(52% 0 0);
}
}
.tremolo-piano {
--height: 160px;
display: inline-block;
box-sizing: border-box;
user-select: none;
touch-action: none;
position: relative;
height: var(--height);
/* The width is the keyboard's own, so a filled piano stretches both ways. */
&[data-fill='true'] {
--height: 100%;
}
}
.tremolo-piano-white-key {
--bg: oklch(100% 0 0);
--color: oklch(0% 0 0);
--active-bg: oklch(92% 0 0);
--active-color: oklch(0% 0 0);
box-sizing: border-box;
position: absolute;
background-color: var(--bg);
-webkit-tap-highlight-color: transparent;
color: var(--color);
border: 1px solid #555;
border-radius: 0px 0px 8px 8px;
cursor: pointer;
z-index: 1;
&[data-active='true'] {
background-color: var(--active-bg);
color: var(--active-color);
}
&[aria-disabled='true'] {
cursor: not-allowed;
}
}
.tremolo-piano-black-key {
--color: oklch(100% 0 0);
--bg: oklch(32% 0 0);
--active-color: oklch(100% 0 0);
--active-bg: oklch(44% 0 0);
box-sizing: border-box;
position: absolute;
background-color: var(--bg);
-webkit-tap-highlight-color: transparent;
color: var(--color);
border: 1px solid #555;
border-radius: 0px 0px 8px 8px;
cursor: pointer;
z-index: 2;
&[data-active='true'] {
background-color: var(--active-bg);
color: var(--active-color);
}
&[aria-disabled='true'] {
cursor: not-allowed;
}
}
.tremolo-piano-key-label-wrapper {
display: flex;
justify-content: center;
align-items: end;
height: 100%;
}
.tremolo-piano-key-label {
box-sizing: content-box;
display: flex;
justify-content: center;
align-items: center;
font-size: 0.6rem;
height: 10px;
margin-bottom: 10px;
padding: 4px;
border-radius: 4px;
border: 1px solid #888;
aspect-ratio: 1;
max-width: calc(100% - 16px);
}
:where(.dark, [data-theme='dark']) {
.tremolo-piano-white-key {
--bg: oklch(80% 0 0);
--color: oklch(0% 0 0);
--active-bg: oklch(72% 0 0);
--active-color: oklch(0% 0 0);
}
}
.tremolo-points-editor {
--width: 200px;
--height: 100px;
position: relative;
width: var(--width);
height: var(--height);
}
.tremolo-points-editor-background {
position: absolute;
inset: 0;
z-index: 0;
}
.tremolo-points-editor-container {
position: absolute;
inset: 0;
z-index: 10;
}
.tremolo-points-editor-point {
--color: oklch(59.5% 0.17548 266.373);
--width: 16px;
--height: 16px;
position: absolute;
width: var(--width);
height: var(--height);
border-radius: 50%;
outline: none;
cursor: grab;
background-color: var(--color);
translate: -50% -50%;
&[data-dragging='true'] {
cursor: inherit;
}
/* Dragging does nothing while readonly, so do not offer to. */
&[aria-readonly='true'] {
cursor: default;
}
/* An outline rather than a box-shadow: focus is usually drawn with a
shadow, and a selection that looks the same is a selection nobody can
tell from focus. */
&[data-selected='true'] {
outline: 2px solid var(--color);
outline-offset: 2px;
}
}
/*
* The rubber band, while a drag on empty space is running. It is drawn over
* the points but takes no pointer events of its own, so the drag underneath
* carries on.
*/
.tremolo-points-editor-marquee {
--color: oklch(59.5% 0.17548 266.373);
position: absolute;
z-index: 20;
pointer-events: none;
border: 1px solid var(--color);
background-color: color-mix(in srgb, var(--color) 12%, transparent);
}
コンポーネント単位のスタイリング
スタイルをグローバルに置く必要はありません。同じノブを CSS Modules で書き、 クラスを対象のパートに渡す例です。
- index.tsx
- my-knob.module.css
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 className={myKnob.knob} value={value} min={0} max={100} size={50} onChange={(v) => setValue(v)} > <Knob.SVGRoot> <Knob.ActiveLine className={myKnob.activeLine} /> <Knob.InactiveLine /> <Knob.Thumb /> </Knob.SVGRoot> </Knob.Root> {value} </div> ) } export default App
.activeLine {
color: rgb(235, 76, 76);
:where(.knob:focus, .knob:hover) & {
color: rgb(235, 44, 44);
}
}
:where(.dark, [data-theme='dark']) {
.activeLine {
:where(.knob:focus, .knob:hover) & {
color: rgb(235, 91, 91);
}
}
}
.container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
次のステップ
tremolo-ui が提供するコンポーネントを見てみましょう。
Components - Knob