StylePanel is a component to set global styling options. It writes to the root HTML element’s data-* attributes to affect global styles in real time. It doesn't persist settings by default.
Choose between light/dark mode and various border radius presets.
Change brand, accent, and neutral palettes. These affect buttons, links, surfaces, and other themed elements.
Adjust the visual language of interactive elements (e.g., plastic vs flat button styling).
Modify surface behavior (translucent vs filled), scale intensity, and transition timing.
Applies the following data-* attributes to the <html> element:
- data-border
- data-brand
- data-accent
- data-neutral
- data-solid
- data-solid-style
- data-theme
- data-transition
- data-scaling
- data-surface
- data-body-size
- data-body-line-height
StylePanel is a plain composition of its own parts. When you need a different arrangement, or only some of the sections, drop it and compose the pieces inside StylePanel.Root:
Rows: Theme, Shape, Brand, Accent, Neutral, Solid, Effect, Surface, Scaling, Transition, DataStyle, BodySize, BodyLineHeight. Groups: Page, Color, SolidStyle, BodyText, Advanced.
Only DataStyle needs a DataThemeProvider in the tree, so a panel without it has no such requirement.
Every row takes a label, and every group a title and description. Pass a node to replace the default, or false to drop it:
To translate the panel, pass labels to the root instead — see Localisation. A prop on a row or group still wins over it.
To keep the default arrangement and hide parts of it, use visibility. Omitted keys stay visible; section: false hides a whole group.
bodyText is the exception: it is opt-in, so pass { bodyText: { section: true } } to show it.
Each row puts its label beside its control, which suits a page or a settings screen. In a sidebar there is no room for both, so the colour strips scroll after a few swatches. stacked puts every label above its control instead:
It is a prop rather than automatic because the panel cannot know it is narrow before it has been laid out: a container query would need size containment, which stops the panel taking its width from its content. Every row, including one with a custom colour, still takes the layout props of a Flex (direction, horizontal, vertical, gap), which win over stacked. Stacked, the option rows take the whole row and drop a button size, so that they fit a sidebar; whatever still overflows scrolls, and the chosen swatch or option is scrolled into view.
By default the panel reads and writes ThemeProvider, which persists to localStorage. Pass value and onChange to take that over — the panel then writes nothing to the providers or to storage, and you decide what a change means:
changed holds only the keys this interaction touched, which is what you want to send to a server; next is the whole state.
That includes the Theme row: it shows value.theme, reading an unset theme as "system", and reports a choice as { theme } without switching the page.
To keep the panel bound to ThemeProvider but change where choices are stored, set persistence on the provider instead — see ThemeProvider.
labels translates every string the panel shows or announces: the group headings, the row labels, the segmented options, the swatches' accessible names and the theme switcher's. Pass it to StylePanel or StylePanel.Root, and every group and row inside reads it, including the ones you compose yourself. Every field at every depth is optional, and whatever you leave out keeps its English default, so you can translate one section at a time.
A row's label and a group's title and description still win over labels, so one composed row can say something else: <StylePanel.Brand label={t("rows.siteColour")} />.
customColors ends the Brand and Accent swatches with one more: a colour of your own, such as a site's brand colour. It is painted with the current seed, and shows a plus while there is none. Choosing it reports { brand: "custom", brandSeed } — the seed already in value, or #5a93fc if there is none — and while it is chosen a colour field under the swatches edits the seed. Every colour picked there comes through onChange, in both next and changed.
For one row only, compose the panel and set custom on that row: <StylePanel.Brand custom />.
It is for the controlled mode. Bound to ThemeProvider, whose setStyle takes no seed, the panel would have nowhere to keep one, so the custom swatch does not render there and the rows look as they always have.
Choosing a named scheme afterwards keeps the seed in the value, so the custom swatch goes on showing it and one click brings it back; the seed only takes effect while the row is "custom". The field's clear button is not reported: the panel keeps the last colour rather than leave the scheme with nothing to point at. The field shows a hex seed; a seed in another format (rgb(), oklch()) still paints the swatch but leaves the field empty until a colour is picked.
The panel never generates CSS. It reports the seed, and your app renders the scheme from it with the same pure function Once UI uses everywhere. On the published site, give the seed to ThemeInit: it generates the scheme on the server and renders it into a <style> beside its script, so the page paints in the site's colour from the first frame, with no flash. ThemeProvider takes the same seed and applies it on the client.
In the editor, where the colour changes as it is picked, write the same properties yourself with customSchemeProperties:
See Custom schemes for how a scheme is generated from one colour.