Skip to main content

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
/*
* 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 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.

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

🧭Next Step​

Let's explore the components offered by tremolo-ui.
Components - Knob