Variants
A variant is an attribute in the markup. data-variant and data-size select a look; data-slean-<option> attributes set a behavior option the stylesheet reads as well. Every selector is wrapped in :where(), so a rule of your own always wins and a new variant is one plain rule.
On this page
Variant attributes
Static markup needs no variant function: the attribute is written in the HTML and the stylesheet selects on it. The table is generated from the package's CSS files, so it lists exactly what each file implements; a primitive without a row in a column has no variant of that kind.
Three look attributes carry no slean prefix, as in shadcn/ui and most component
libraries: data-variant (a look: outline, danger, …), data-size (sm, lg; medium has no attribute) and data-status (error, warning on a form control). They
select styles only; no behavior reads them. Behavior options keep the prefix (data-slean-orientation, data-slean-side) because a behavior reads them too. All of them are part of the
DOM protocol (ADR 0002) and change only with a deprecation window.
| Primitive | Tier | data-variant | data-size | Options | Modifiers | Parts |
|---|---|---|---|---|---|---|
button | 0 | outline, secondary, soft, ghost, danger, link | sm, lg | none | data-icon-only | none |
dialog | 0 | none | sm, lg | none | none | header, title, description, body, footer, close |
popover | 0 | none | none | data-slean-side: top | left | right; data-slean-align: start | end | none | none |
disclosure | 0 | none | none | none | none | content |
checkbox | 0 | none | none | none | none | none |
switch | 0 | none | none | none | none | none |
radio-group | 0 | none | none | data-slean-orientation: horizontal | none | none |
tabs | 1 | none | none | data-slean-orientation: vertical | none | list, trigger, panel |
menu | 1 | danger | none | none | none | submenu-trigger, label, shortcut |
listbox | 1 | none | none | data-slean-orientation: horizontal | none | list, label |
control | – | none | sm, lg | none | none | input, trigger, clear, toggle, icon, separator, value, popup, group-label, empty |
combobox | 2 | none | none | none | none | empty, toggle, clear |
date-field | 0 | none | none | none | none | none |
calendar | 1 | none | none | data-slean-mode: range; data-slean-edge: start | end | both | none | grid, header, title, previous, next, previous-year, next-year, day |
date-picker | 2 | none | none | none | none | separator, input, icon, popup, footer, preset, today, native, clear |
input | 0 | none | sm, lg | none | none | none |
field | 0 | none | none | data-slean-orientation: horizontal | none | label, description, error |
select | 2 | none | sm, lg | none | none | native, trigger, value, placeholder, tag, remove, clear, toggle, popup |
segmented | 0 | none | none | none | none | none |
rating | 0 | none | none | none | none | none |
slider | 0 | native | none | data-slean-orientation: vertical | none | none |
otp-field | 0 | none | none | data-slean-length: 4 | 8 | none | none |
file-field | 0 | none | none | none | none | none |
color-field | 0 | none | none | none | none | none |
autocomplete | 0 | none | none | none | none | none |
alert-dialog | 0 | none | none | none | none | header, title, description, footer |
drawer | 0 | none | none | data-slean-side: left | top | bottom | none | header, title, description, body, footer, close |
accordion | 0 | none | none | none | none | content |
card | 0 | filled | none | none | none | media, header, title, description, body, footer, link |
separator | 0 | none | none | none | none | none |
avatar | 0 | group, square | sm, lg | none | none | fallback |
badge | 0 | accent, success, warning, danger | sm, lg | none | none | none |
tag | 0 | accent | none | none | none | remove |
kbd | 0 | none | none | none | none | none |
carousel | 0 | none | none | none | none | viewport, slide |
alert | 0 | success, warning, danger, info | none | none | none | title, actions, icon |
progress | 0 | none | sm, lg | none | none | none |
meter | 0 | none | sm, lg | none | none | none |
spinner | 0 | none | sm, lg | none | none | label |
skeleton | 0 | text, circle, block | sm, lg | none | none | none |
breadcrumb | 0 | none | none | none | none | none |
pagination | 0 | none | none | none | none | ellipsis, previous, next |
steps | 0 | none | none | data-slean-orientation: vertical | none | title, description, complete |
toggle | 1 | none | sm, lg | none | data-icon-only | none |
toggle-group | 1 | none | sm, lg | data-slean-orientation: vertical | none | item |
toolbar | 1 | none | none | data-slean-orientation: vertical | none | none |
range-slider | 1 | none | none | none | none | track, start, end |
number-field | 1 | none | none | none | none | input, decrement, increment |
tree | 1 | none | none | none | none | label |
tooltip | 1 | none | none | data-slean-side: bottom | left | right | none | trigger, content |
toast | 2 | success, warning, danger | none | none | none | list, item, title, description, action, close |
button-group | 0 | soft, danger | none | data-slean-orientation: vertical | data-variant | none |
scroll-area | 0 | outline | none | data-slean-orientation: horizontal | none | none |
descriptions | 0 | outline | none | data-slean-columns: 1 | 2 | 3 | 4; data-slean-layout: inline | none | none |
timeline | 0 | info, success, warning, danger | none | none | none | title, description, pending |
empty | 0 | outline | none | none | none | icon, title, description, actions |
splitter | 2 | none | none | data-slean-orientation: vertical | none | pane, handle |
context-menu | 1 | none | none | none | none | target, menu |
file-drop | 1 | none | none | none | none | output, input |
hover-card | 1 | none | none | data-slean-side: top | left | right | none | trigger, content |
menubar | 1 | none | none | none | none | none |
Tiers come from the primitives package metadata; everything else is read from packages/styles/css/<name>.css.
Button
Seven looks, three sizes, an icon-only square, a busy state and the disabled state. The default
look is the solid accent; data-variant changes the surface and data-size the control height (32, 36 and 44px, the heights every form control shares)
and padding.
Dialog
data-size sets the dialog's inline size; the default sits between the two. The open
state is the native [open] attribute and the entrance uses @starting-style, both from the stylesheet, none from JavaScript.
<button type="button" data-slean="button" data-variant="outline" commandfor="dialog-sm" command="show-modal">
Small dialog
</button>
<dialog id="dialog-sm" data-slean="dialog" data-size="sm" aria-labelledby="dialog-sm-title">
<header data-slean-part="header">
<h2 id="dialog-sm-title" data-slean-part="title">Small</h2>
<p data-slean-part="description">data-size="sm"</p>
</header>
<footer data-slean-part="footer">
<button type="button" data-slean="button" data-variant="outline" commandfor="dialog-sm" command="close">
Close
</button>
</footer>
</dialog>
<button type="button" data-slean="button" data-variant="outline" commandfor="dialog-lg" command="show-modal">
Large dialog
</button>
<dialog id="dialog-lg" data-slean="dialog" data-size="lg" aria-labelledby="dialog-lg-title">
<header data-slean-part="header">
<h2 id="dialog-lg-title" data-slean-part="title">Large</h2>
<p data-slean-part="description">data-size="lg"</p>
</header>
<footer data-slean-part="footer">
<button type="button" data-slean="button" data-variant="outline" commandfor="dialog-lg" command="close">
Close
</button>
</footer>
</dialog><script lang="ts">
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/dialog.css';
</script>
<button type="button" data-slean="button" data-variant="outline" commandfor="dialog-sm" command="show-modal">
Small dialog
</button>
<dialog id="dialog-sm" data-slean="dialog" data-size="sm" aria-labelledby="dialog-sm-title">
<header data-slean-part="header">
<h2 id="dialog-sm-title" data-slean-part="title">Small</h2>
<p data-slean-part="description">data-size="sm"</p>
</header>
<footer data-slean-part="footer">
<button type="button" data-slean="button" data-variant="outline" commandfor="dialog-sm" command="close">
Close
</button>
</footer>
</dialog>
<button type="button" data-slean="button" data-variant="outline" commandfor="dialog-lg" command="show-modal">
Large dialog
</button>
<dialog id="dialog-lg" data-slean="dialog" data-size="lg" aria-labelledby="dialog-lg-title">
<header data-slean-part="header">
<h2 id="dialog-lg-title" data-slean-part="title">Large</h2>
<p data-slean-part="description">data-size="lg"</p>
</header>
<footer data-slean-part="footer">
<button type="button" data-slean="button" data-variant="outline" commandfor="dialog-lg" command="close">
Close
</button>
</footer>
</dialog>Popover
data-slean-side and data-slean-align are placement options. The
invoker gives the popover an implicit anchor, and the stylesheet places it with position-area and flip fallbacks. Where anchor positioning is unsupported the popover
keeps the platform default, centered in the top layer.
<button type="button" data-slean="button" data-variant="outline" popovertarget="pop-bottom">Bottom</button>
<div id="pop-bottom" popover data-slean="popover">Default: below the invoker, centered.</div>
<button type="button" data-slean="button" data-variant="outline" popovertarget="pop-top">Top</button>
<div id="pop-top" popover data-slean="popover" data-slean-side="top">data-slean-side="top"</div>
<button type="button" data-slean="button" data-variant="outline" popovertarget="pop-right">Right</button>
<div id="pop-right" popover data-slean="popover" data-slean-side="right">data-slean-side="right"</div>
<button type="button" data-slean="button" data-variant="outline" popovertarget="pop-start">Bottom, start</button>
<div id="pop-start" popover data-slean="popover" data-slean-align="start">data-slean-align="start"</div>
<button type="button" data-slean="button" data-variant="outline" popovertarget="pop-end">Bottom, end</button>
<div id="pop-end" popover data-slean="popover" data-slean-align="end">data-slean-align="end"</div><script lang="ts">
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/popover.css';
</script>
<button type="button" data-slean="button" data-variant="outline" popovertarget="pop-bottom">Bottom</button>
<div id="pop-bottom" popover data-slean="popover">Default: below the invoker, centered.</div>
<button type="button" data-slean="button" data-variant="outline" popovertarget="pop-top">Top</button>
<div id="pop-top" popover data-slean="popover" data-slean-side="top">data-slean-side="top"</div>
<button type="button" data-slean="button" data-variant="outline" popovertarget="pop-right">Right</button>
<div id="pop-right" popover data-slean="popover" data-slean-side="right">data-slean-side="right"</div>
<button type="button" data-slean="button" data-variant="outline" popovertarget="pop-start">Bottom, start</button>
<div id="pop-start" popover data-slean="popover" data-slean-align="start">data-slean-align="start"</div>
<button type="button" data-slean="button" data-variant="outline" popovertarget="pop-end">Bottom, end</button>
<div id="pop-end" popover data-slean="popover" data-slean-align="end">data-slean-align="end"</div>Tabs
data-slean-orientation="vertical" is a behavior option: the tabs behavior maps the up
and down arrow keys instead of left and right, and the stylesheet moves the indicator to the inline-end
edge of the list. One attribute, read by both.
<div data-slean="tabs" data-slean-value="general" data-slean-orientation="vertical">
<div role="tablist" aria-label="Preferences" aria-orientation="vertical" data-slean-part="list">
<button type="button" id="tab-general" role="tab" aria-controls="panel-general"
aria-selected="true" tabindex="0" data-slean-part="trigger" data-slean-value="general">
General
</button>
<button type="button" id="tab-privacy" role="tab" aria-controls="panel-privacy"
aria-selected="false" tabindex="-1" data-slean-part="trigger" data-slean-value="privacy">
Privacy
</button>
</div>
<div id="panel-general" role="tabpanel" aria-labelledby="tab-general"
data-slean-part="panel" data-slean-value="general">
General preferences.
</div>
<div id="panel-privacy" role="tabpanel" aria-labelledby="tab-privacy"
data-slean-part="panel" data-slean-value="privacy" hidden>
Privacy preferences.
</div>
</div><script lang="ts">
// Injected by @svelte-lean/vite for the static marker; written by hand without the plugin.
import '@svelte-lean/primitives/tabs/register';
import '@svelte-lean/styles/tabs.css';
</script>
<div data-slean="tabs" data-slean-value="general" data-slean-orientation="vertical">
<div role="tablist" aria-label="Preferences" aria-orientation="vertical" data-slean-part="list">
<button type="button" id="tab-general" role="tab" aria-controls="panel-general"
aria-selected="true" tabindex="0" data-slean-part="trigger" data-slean-value="general">
General
</button>
<button type="button" id="tab-privacy" role="tab" aria-controls="panel-privacy"
aria-selected="false" tabindex="-1" data-slean-part="trigger" data-slean-value="privacy">
Privacy
</button>
</div>
<div id="panel-general" role="tabpanel" aria-labelledby="tab-general"
data-slean-part="panel" data-slean-value="general">
General preferences.
</div>
<div id="panel-privacy" role="tabpanel" aria-labelledby="tab-privacy"
data-slean-part="panel" data-slean-value="privacy" hidden>
Privacy preferences.
</div>
</div>State selectors
State is read from the platform and from ARIA, never from a mirrored attribute: a checkbox is
styled on :checked, a dialog on [open], a tab on aria-selected. There is no data-state="active" to keep in sync, which is
why the stylesheet is correct for server-rendered markup before any script runs. The selectors below
are read from each file.
| Primitive | State selectors used |
|---|---|
button | :disabled,:hover,:active,[aria-disabled="true"],[aria-busy="true"] |
dialog | :hover,[open] |
popover | :popover-open |
disclosure | :focus-visible,:hover,[open] |
checkbox | :checked,:indeterminate,:disabled,:user-invalid,:hover,[aria-invalid="true"] |
switch | :checked,:disabled,:hover |
radio-group | :checked,:disabled,:user-invalid,:hover,[aria-invalid="true"] |
tabs | :hover,:disabled,:focus-visible,[aria-disabled="true"],[aria-selected="true"] |
menu | :hover,:disabled,:focus-visible,[aria-disabled="true"],[aria-expanded="true"],[aria-checked="true"] |
listbox | :hover,:focus-visible,[aria-disabled="true"],[aria-selected="true"] |
control | :focus-within,:user-invalid,:hover,:focus-visible,:disabled,:placeholder-shown,:popover-open,[aria-invalid="true"],[aria-expanded="true"],[aria-disabled="true"] |
combobox | none |
date-field | :disabled,:focus-visible,:user-invalid,:hover,[aria-invalid="true"] |
calendar | :hover,[aria-disabled="true"],[aria-current="date"],[aria-selected="true"] |
date-picker | :hover |
input | :disabled,:user-invalid,:hover,:focus-visible,[aria-invalid="true"] |
field | :user-invalid,:disabled |
select | :disabled,:user-invalid,:open,:checked,:hover,:focus-visible,[aria-invalid="true"] |
segmented | :checked,:focus-visible,:disabled,:hover |
rating | :checked,:disabled,:hover,:focus-visible |
slider | :disabled,:focus-visible,:hover |
otp-field | :focus-visible,:user-invalid,:disabled,:placeholder-shown,:hover,[aria-invalid="true"] |
file-field | :focus-visible,:user-invalid,:disabled,:hover,[aria-invalid="true"] |
color-field | :focus-visible,:disabled,:hover,:user-invalid,[aria-invalid="true"] |
autocomplete | :focus-visible,:user-invalid,:disabled,:hover,[aria-invalid="true"] |
alert-dialog | [open] |
drawer | :hover,[open] |
accordion | :focus-visible,:hover,[open] |
card | :focus-visible,:hover |
separator | [aria-orientation="vertical"] |
avatar | none |
badge | none |
tag | :disabled,:focus-visible,:hover |
kbd | none |
carousel | :disabled |
alert | none |
progress | :indeterminate |
meter | none |
spinner | none |
skeleton | none |
breadcrumb | :hover,:focus-visible,[aria-current="page"] |
pagination | :focus-visible,:hover,[aria-current="page"] |
steps | [aria-current="step"] |
toggle | :disabled,:hover,:active,[aria-pressed="true"],[aria-pressed="mixed"],[aria-disabled="true"] |
toggle-group | :hover,:disabled,[aria-disabled="true"],[aria-pressed="true"] |
toolbar | [aria-pressed="true"] |
range-slider | :focus-visible,:disabled |
number-field | :disabled,:focus-visible,:user-invalid,:hover,[aria-invalid="true"] |
tree | :focus-visible,:hover,[aria-expanded="false"],[aria-expanded],[aria-expanded="true"],[aria-disabled="true"],[aria-selected="true"] |
tooltip | :popover-open |
toast | :hover |
button-group | :focus-visible,:hover,:disabled,[aria-disabled="true"] |
scroll-area | none |
descriptions | none |
timeline | none |
empty | none |
splitter | :focus-visible,:hover,[aria-disabled="true"] |
context-menu | :focus-visible,:popover-open |
file-drop | :focus-visible,:disabled,:hover,:user-invalid |
hover-card | :popover-open |
menubar | :popover-open,:focus-visible,:hover,[aria-disabled="true"] |
Adding your own variants
A new variant is a value the package has never seen and one unlayered rule. The base rule of the package still applies (height, padding, focus ring, disabled state), so the new rule adds the difference and nothing else.
/* A variant the package does not know: an unlayered rule of your own. The package styles the
* base look and every state; your rule adds the difference. */
[data-slean='button'][data-variant='brand'] {
background: oklch(45% 0.12 300);
color: oklch(100% 0 0);
}
[data-slean='button'][data-variant='brand']:hover {
background: oklch(40% 0.12 300);
}<button type="button" data-slean="button" data-variant="brand">Brand</button>
<button type="button" data-slean="button" data-variant="brand" data-size="sm">Brand, small</button><script lang="ts">
import '@svelte-lean/styles/button.css';
</script>
<button type="button" data-slean="button" data-variant="brand">Brand</button>
<button type="button" data-slean="button" data-variant="brand" data-size="sm">Brand, small</button>
<style>
:global([data-slean='button'][data-variant='brand']) {
background: oklch(45% 0.12 300);
color: oklch(100% 0 0);
}
</style>The full files are in packages/styles/css; the conventions they follow are in packages/styles/AGENTS.md.