Styling
tremolo-ui ships no CSS. A component renders its markup, keeps track of the value, and handles the pointer, keyboard and wheel; what it looks like is yours. Style it with vanilla CSS, CSS Modules, Tailwind, CSS-in-JS — whatever the rest of your app already uses.
Your classes, on every part
No class names are shipped. Every part takes a className, and what you
pass is the only class it carries — nothing of ours is there to be overridden,
and no stylesheet of ours can reach in.
<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 works the same way, and the parts that draw more than one element take
a classes object for the pieces inside them — Slider.MarksOption has
classes={{ mark, label }}, and Piano reaches its keys through keyProps.
The theme below is written as CSS Modules for exactly this: each file exports one class per part, and you wire them up as above.
State
State is on the element as an attribute, not as an extra class, so it is a
selector away. A state that is off leaves no attribute behind, so the
attribute alone is the selector — there is no ="false" to match by mistake.
Each component lists the attributes of every part on its own page, under
API Reference — Knob,
Slider,
XYPad,
NumberInput,
Piano and
PointsEditor. The ones you
will reach for most are [data-disabled] and [data-readonly].
The controls that are one in their own right — Knob, NumberInput.InputField,
the steppers, and the range inputs inside a thumb — carry the matching ARIA as
well (aria-disabled, aria-readonly). Those say what the control is;
the data- attributes are what the styles read.
.slider[data-disabled] {
opacity: 0.5;
}
:focus, :hover and :focus-within work as they would anywhere, since the
focusable element is a real one.
Custom properties
Style single CSS declarations directly, through a class or style. Some props
write custom properties when the same value serves several declarations:
<Slider.Track thickness={16} />
// is exactly
<Slider.Track style={{ '--thickness': '16px' }} />
For these props, a stylesheet and an inline style are other ways to set the same value, and the usual cascade decides between them. The default is in the CSS, not in the component, so a rule of yours is enough to change it everywhere.
.track {
--thickness: 16px;
}
A number passed as a prop is taken as pixels; anything else is written through
as-is, so '3rem', '100%' and 'auto' all work.
style takes custom properties on every part, with no cast. React's own
CSSProperties has no key starting with --, so TypeScript would normally
refuse style={{ '--thickness': '16px' }}. The parts widen it with
CSSVariables: the properties a part writes or reads are suggested by your
editor, and any other -- name — a theme's own — is accepted as well.
Which properties a part has is listed on its own page, under API Reference — Knob, Slider, XYPad, NumberInput, Piano and PointsEditor.
A theme adds variables of its own on top of those — the one below has
--thumb-size and the track's --axis. Those are the theme's to name, and
they are only there because it reads them.
The theme used on this site
Every example in these docs is styled by the files below. They are the starting point rather than a dependency: copy the ones you need into your own project and edit them.
Each is plain CSS with no build step. Custom properties such as --knob-size
and --thumb-size keep values used in several places together; a size or
colour used once is a regular CSS declaration.
They say what the parts look like, not where they go. A component places
its own parts — a thumb is taken out of flow and centred on the value by the
component itself — so a theme of your own cannot put one in the wrong place by
leaving a rule out. Where that placement has room for taste, it reads a custom
property: --translate on a thumb, a mark or a point moves it off its centre.
- Knob.module.css
- Slider.module.css
- XYPad.module.css
- NumberInput.module.css
- Piano.module.css
- DropZone.module.css
- FileInput.module.css
- PointsEditor.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);
}
/*
* The demo theme for `Slider`, as a CSS Module: one class per part, applied by
* the caller. Copy this file into your project and edit it — it is a starting
* point, not a dependency.
*
* It says what the parts look like, not where they go: the component places
* them itself.
*/
/*
* The size the thumb is drawn at, and the room the slider leaves for it. Both
* parts need the value and nothing inherits it, so it is composed into each
* of them and stays written once.
*/
.thumbSize {
--thumb-size: 22px;
}
.root {
composes: thumbSize;
display: inline-flex;
flex-direction: column;
margin: calc(var(--thumb-size) / 2);
cursor: pointer;
outline: 0;
-webkit-tap-highlight-color: transparent;
&[data-orientation='vertical'] {
flex-direction: row;
}
&[data-readonly] {
cursor: not-allowed;
}
}
.thumb {
composes: thumbSize;
--color: oklch(59.5% 0.17548 266.373);
width: var(--thumb-size);
height: var(--thumb-size);
background: var(--color);
border-radius: 50%;
outline: none;
&[data-disabled] {
background: oklch(50.462% 0.03307 270.021);
}
&:focus-within {
box-shadow: 0px 0px 0px 3px
color-mix(in srgb, var(--color) 20%, transparent);
}
}
.track {
--active-color: oklch(69.212% 0.12903 267.633);
--inactive-color: 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;
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-color) var(--percent),
var(--inactive-color) var(--percent)
);
&[data-flipped] {
background: linear-gradient(
var(--axis),
var(--inactive-color) var(--percent),
var(--active-color) var(--percent)
);
}
&[data-orientation='horizontal'] {
min-width: 50px;
}
&[data-orientation='vertical'] {
--axis: to bottom;
width: var(--thickness);
height: var(--length);
min-height: 50px;
}
&:hover {
--active-color: oklch(64.276% 0.15244 266.932);
--inactive-color: #e0e0e0;
}
&[data-disabled] {
--active-color: oklch(75.327% 0.01237 264.547);
--inactive-color: oklch(89.755% 0.0001 271.152);
}
}
.marks {
/* Space between the marks and the track. */
--gap: 6px;
display: block;
margin-top: var(--gap);
&[data-orientation='vertical'] {
margin-top: 0;
margin-left: var(--gap);
}
}
.marksOption {
/* Read by the mark and the label below, which is why they are set here. */
--thickness: 1px;
--length: 0.5rem;
--gap: 2px;
display: flex;
color: oklch(25.197% 0.00003 271.152);
flex-direction: column;
justify-content: center;
align-items: center;
&[data-orientation='vertical'] {
flex-direction: row;
}
}
.mark {
background-color: currentColor;
width: var(--thickness);
height: var(--length);
margin-bottom: var(--gap);
&[data-orientation='vertical'] {
width: var(--length);
height: var(--thickness);
margin-bottom: 0;
margin-right: var(--gap);
}
}
.label {
text-align: right;
}
/*
* Dark mode follows whatever marks the page — `.dark` and `[data-theme=dark]`
* are what this site and Storybook set. Swap them for however your own app
* says it, or read `prefers-color-scheme` instead.
*/
:global(.dark) .thumb,
:global([data-theme='dark']) .thumb {
--color: oklch(64.5% 0.17548 266.373);
&:hover {
--color: oklch(67.5% 0.17548 266.373);
}
}
:global(.dark) .track,
:global([data-theme='dark']) .track {
--active-color: oklch(59.5% 0.17548 266.373);
--inactive-color: oklch(41.7% 0 0);
&:hover {
--active-color: oklch(62.5% 0.17548 266.373);
--inactive-color: oklch(44.7% 0 0);
}
&[data-disabled] {
--active-color: oklch(75.327% 0.01237 264.547);
--inactive-color: oklch(89.755% 0.0001 271.152);
}
}
/*
* The demo theme for `XYPad`, as a CSS Module: one class per part, applied by
* the caller. Copy this file into your project and edit it.
*/
/*
* The size the thumb is drawn at, the room the pad leaves for it, and the
* smallest the area may become. Three parts need the value and nothing
* inherits it, so it is composed into each of them.
*/
.thumbSize {
--thumb-size: 22px;
}
.root {
composes: thumbSize;
display: inline-block;
margin: calc(var(--thumb-size) / 2);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&[data-readonly] {
cursor: not-allowed;
}
}
.area {
composes: thumbSize;
/* Room for the thumb to travel. A percentage size, or a flex row that
overrides the automatic minimum size, may shrink the area to nothing
with the thumb left sitting on a line. */
--min-width: calc(var(--thumb-size) * 2);
--min-height: calc(var(--thumb-size) * 2);
width: 120px;
height: 120px;
min-width: var(--min-width);
min-height: var(--min-height);
background-color: oklch(95% 0 0);
}
.thumb {
composes: thumbSize;
--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);
&[data-disabled] {
background-color: oklch(50.462% 0.03307 270.021);
}
&:focus-within {
box-shadow: 0px 0px 0px 3px
color-mix(in srgb, var(--color) 20%, transparent);
}
}
/*
* Dark mode follows whatever marks the page — `.dark` and `[data-theme=dark]`
* are what this site and Storybook set.
*/
:global(.dark) .area,
:global([data-theme='dark']) .area {
background-color: oklch(41.7% 0 0);
}
:global(.dark) .thumb,
:global([data-theme='dark']) .thumb {
--color: oklch(64.5% 0.17548 266.373);
&:hover {
--color: oklch(67.5% 0.17548 266.373);
}
}
/*
* The demo theme for `NumberInput`, as a CSS Module: one class per part, applied by
* the caller. Copy this file into your project and edit it.
*/
.root {
--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(.field[data-out-of-range]) {
border-color: oklch(64.245% 0.21266 25.499);
}
/* Only leave room for the stepper when one was composed in. */
&:not(:has(.stepper)) {
--stepper-width: 0px;
}
}
.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;
&[data-readonly] {
cursor: not-allowed;
}
}
.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(.root:hover, .root:focus-within) .stepper {
opacity: 1;
transform: translateX(0);
}
:is(.incrementStepper, .decrementStepper) {
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;
&:not([data-disabled]):active {
background-color: oklch(37.798% 0.03812 254.612 / 0.055);
}
&[data-disabled],
&[data-readonly] {
cursor: not-allowed;
}
}
.incrementStepper {
border-top-right-radius: var(--border-radius);
}
.decrementStepper {
border-top: 1px solid oklch(84.522% 0 0);
border-bottom-right-radius: var(--border-radius);
}
/*
* Dark mode follows whatever marks the page — `.dark` and `[data-theme=dark]`
* are what this site and Storybook set.
*/
:global(.dark) .root,
:global([data-theme='dark']) .root {
--border-color: oklch(52% 0 0);
}
/*
* The demo theme for `Piano`, as a CSS Module: one class per part, applied by
* the caller. Copy this file into your project and edit it.
*/
.root {
display: inline-block;
box-sizing: border-box;
user-select: none;
touch-action: none;
height: 160px;
/* The width is the keyboard's own, so a resizable piano stretches both ways. */
&[data-resizable] {
height: 100%;
}
}
.whiteKey {
box-sizing: border-box;
background-color: oklch(100% 0 0);
-webkit-tap-highlight-color: transparent;
color: oklch(0% 0 0);
border: 1px solid #555;
border-radius: 0px 0px 8px 8px;
cursor: pointer;
&[data-active] {
background-color: oklch(92% 0 0);
color: oklch(0% 0 0);
}
&[data-disabled] {
cursor: not-allowed;
}
}
.blackKey {
box-sizing: border-box;
background-color: oklch(32% 0 0);
-webkit-tap-highlight-color: transparent;
color: oklch(100% 0 0);
border: 1px solid #555;
border-radius: 0px 0px 8px 8px;
cursor: pointer;
&[data-active] {
background-color: oklch(44% 0 0);
color: oklch(100% 0 0);
}
&[data-disabled] {
cursor: not-allowed;
}
}
.keyLabelWrapper {
display: flex;
justify-content: center;
align-items: end;
height: 100%;
}
.keyLabel {
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);
}
/*
* Dark mode follows whatever marks the page — `.dark` and `[data-theme=dark]`
* are what this site and Storybook set.
*/
:global(.dark) .whiteKey,
:global([data-theme='dark']) .whiteKey {
background-color: oklch(80% 0 0);
color: oklch(0% 0 0);
&[data-active] {
background-color: oklch(72% 0 0);
color: oklch(0% 0 0);
}
}
/*
* The demo theme for `DropZone`, as a CSS Module: one class per part, applied by
* the caller. Copy this file into your project and edit it.
*/
.root {
--active-color: oklch(59.5% 0.17548 266.373);
--invalid-color: oklch(64.245% 0.21266 25.499);
--border-color: oklch(81% 0 0);
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
padding: 1.5rem;
border: 2px dashed var(--border-color);
border-radius: 8px;
text-align: center;
transition: all 0.1s;
/* Present only while files are over the element, so the two states need no
class of their own. */
&[data-dragover] {
--border-color: var(--active-color);
background: color-mix(in srgb, var(--active-color) 8%, transparent);
}
&[data-dragover][data-invalid] {
--border-color: var(--invalid-color);
background: color-mix(in srgb, var(--invalid-color) 8%, transparent);
cursor: not-allowed;
}
&[data-disabled] {
--border-color: oklch(87% 0 0);
color: oklch(65.335% 0 0);
cursor: not-allowed;
}
}
/*
* Dark mode follows whatever marks the page — `.dark` and `[data-theme=dark]`
* are what this site and Storybook set.
*/
:global(.dark) .root,
:global([data-theme='dark']) .root {
--border-color: oklch(52% 0 0);
&[data-disabled] {
--border-color: oklch(38% 0 0);
}
}
/*
* The demo theme for `FileInput`, as a CSS Module: one class per part, applied by
* the caller. Copy this file into your project and edit it.
*/
.root {
--active-color: oklch(59.5% 0.17548 266.373);
--border-color: oklch(81% 0 0);
display: inline-flex;
align-items: center;
gap: 0.5rem;
/* The `<input type="file">` is inside and visually hidden, so the focus ring
has to be drawn by something that can be seen. */
&:focus-within .trigger {
border-color: var(--active-color);
box-shadow: 0px 0px 0px 2px
color-mix(in srgb, var(--active-color) 10%, transparent);
}
&[data-disabled] {
--border-color: oklch(87% 0 0);
color: oklch(65.335% 0 0);
}
}
.trigger {
display: inline-block;
padding: 6px 14px;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
user-select: none;
transition: all 0.1s;
&:hover {
--border-color: oklch(65.335% 0 0);
}
&[data-disabled] {
cursor: not-allowed;
&:hover {
--border-color: oklch(87% 0 0);
}
}
}
/*
* Dark mode follows whatever marks the page — `.dark` and `[data-theme=dark]`
* are what this site and Storybook set.
*/
:global(.dark) .root,
:global([data-theme='dark']) .root {
--border-color: oklch(52% 0 0);
&[data-disabled] {
--border-color: oklch(38% 0 0);
}
.trigger:hover {
--border-color: oklch(65.335% 0 0);
}
.trigger[data-disabled]:hover {
--border-color: oklch(38% 0 0);
}
}
/*
* The demo theme for `PointsEditor`, as a CSS Module: one class per part, applied by
* the caller. Copy this file into your project and edit it.
*/
.root {
width: 200px;
height: 100px;
}
.point {
--color: oklch(59.5% 0.17548 266.373);
width: 16px;
height: 16px;
border-radius: 50%;
outline: none;
cursor: grab;
background-color: var(--color);
&[data-dragging] {
cursor: inherit;
}
/* Dragging does nothing while readonly, so do not offer to. */
&[data-readonly] {
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] {
outline: 2px solid var(--color);
outline-offset: 2px;
}
&:focus-within {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color) 20%, transparent);
}
}
/* The selection box, while a drag on empty space is running. */
.selectionBox {
--color: oklch(59.5% 0.17548 266.373);
border: 1px solid var(--color);
background-color: color-mix(in srgb, var(--color) 12%, transparent);
}
Dark mode
The theme reads whatever marks the page: .dark on an ancestor, or
[data-theme="dark"], which is what this site and Storybook set.
:global(.dark) .thumb,
:global([data-theme='dark']) .thumb {
--color: oklch(64.5% 0.17548 266.373);
}
They are two selectors rather than one because a CSS Module leaves :global
where you write it. Swap them for however your own app says dark — a class of
your own, or @media (prefers-color-scheme: dark) if it follows the system.
Whatever you already use
A class is a class: the same knob below is written twice, once with CSS Modules
and once with Tailwind. Neither is special to the library — the parts take a
className and the rest is your own stylesheet.
- CSS Modules
- Tailwind
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
.knob {/* The component writes `--knob-size` and nothing else: the box is sizedhere. Without a width and height the knob has no size at all. */width: var(--knob-size);height: var(--knob-size);flex-shrink: 0;cursor: grab;outline: 0;}.activeLine {color: rgb(235, 76, 76);:where(.knob:focus, .knob:hover) & {color: rgb(235, 44, 44);}}.inactiveLine {color: oklch(92.2% 0 0);:where(.knob:focus, .knob:hover) & {color: oklch(87% 0 0);}}.thumb {color: oklch(87% 0 0);:where(.knob:focus, .knob:hover) & {color: oklch(70.8% 0 0);}}/* The line on the thumb that points at the value. */.thumbLine {color: oklch(98.5% 0 0);}/* `:global`, or the module renames `.dark` along with its own classes. */:global(.dark) .activeLine,:global([data-theme='dark']) .activeLine {:where(.knob:focus, .knob:hover) & {color: rgb(235, 91, 91);}}:global(.dark) .inactiveLine,:global([data-theme='dark']) .inactiveLine {color: oklch(37.1% 0 0);:where(.knob:focus, .knob:hover) & {color: oklch(43.9% 0 0);}}:global(.dark) .thumb,:global([data-theme='dark']) .thumb {color: oklch(43.9% 0 0);:where(.knob:focus, .knob:hover) & {color: oklch(55.6% 0 0);}}:global(.dark) .thumbLine,:global([data-theme='dark']) .thumbLine {color: oklch(87% 0 0);}.container {display: flex;justify-content: center;align-items: center;flex-direction: column;}
import { useState } from 'react' import { Knob } from '@tremolo-ui/react' function App() { const [value, setValue] = useState(64) return ( <div className="flex flex-col items-center justify-center"> <Knob.Root aria-label="Level" // `group` so the lines can follow the knob's own hover and focus. className="group size-[50px] shrink-0 cursor-grab outline-0" value={value} min={0} max={100} size={50} onChange={(v) => setValue(v)} > <Knob.SVGRoot> <Knob.InactiveLine className="text-neutral-200 group-hover:text-neutral-300 dark:text-neutral-700 dark:group-hover:text-neutral-600" /> <Knob.ActiveLine className="text-red-500 group-hover:text-red-600 group-focus:text-red-600 dark:text-red-600 dark:group-hover:text-red-500 dark:group-focus:text-red-500" /> <Knob.Thumb className="text-neutral-300 group-hover:text-neutral-400 dark:text-neutral-600 dark:group-hover:text-neutral-500" classes={{ line: 'text-neutral-50 dark:text-neutral-300' }} /> </Knob.SVGRoot> </Knob.Root> {value} </div> ) } export default App
The hover and focus of the lines come from group on the root, since they
follow the knob rather than themselves. size still writes --knob-size,
so an arbitrary value reads it where a theme would.
Dark is dark:, the same as anywhere else in a Tailwind app. Out of the box
it follows prefers-color-scheme, so a theme toggle — this site's included —
needs one line pointing the variant at whatever marks the page:
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
Next Step
Let's explore the components offered by tremolo-ui.
Components - Knob