sveltelean
Versionv0.2.0 GitHub

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.

PrimitiveTierdata-variantdata-sizeOptionsModifiersParts
button0outline, secondary, soft, ghost, danger, linksm, lgnonedata-icon-onlynone
dialog0nonesm, lgnonenoneheader, title, description, body, footer, close
popover0nonenonedata-slean-side: top | left | right; data-slean-align: start | endnonenone
disclosure0nonenonenonenonecontent
checkbox0nonenonenonenonenone
switch0nonenonenonenonenone
radio-group0nonenonedata-slean-orientation: horizontalnonenone
tabs1nonenonedata-slean-orientation: verticalnonelist, trigger, panel
menu1dangernonenonenonesubmenu-trigger, label, shortcut
listbox1nonenonedata-slean-orientation: horizontalnonelist, label
control–nonesm, lgnonenoneinput, trigger, clear, toggle, icon, separator, value, popup, group-label, empty
combobox2nonenonenonenoneempty, toggle, clear
date-field0nonenonenonenonenone
calendar1nonenonedata-slean-mode: range; data-slean-edge: start | end | bothnonegrid, header, title, previous, next, previous-year, next-year, day
date-picker2nonenonenonenoneseparator, input, icon, popup, footer, preset, today, native, clear
input0nonesm, lgnonenonenone
field0nonenonedata-slean-orientation: horizontalnonelabel, description, error
select2nonesm, lgnonenonenative, trigger, value, placeholder, tag, remove, clear, toggle, popup
segmented0nonenonenonenonenone
rating0nonenonenonenonenone
slider0nativenonedata-slean-orientation: verticalnonenone
otp-field0nonenonedata-slean-length: 4 | 8nonenone
file-field0nonenonenonenonenone
color-field0nonenonenonenonenone
autocomplete0nonenonenonenonenone
alert-dialog0nonenonenonenoneheader, title, description, footer
drawer0nonenonedata-slean-side: left | top | bottomnoneheader, title, description, body, footer, close
accordion0nonenonenonenonecontent
card0fillednonenonenonemedia, header, title, description, body, footer, link
separator0nonenonenonenonenone
avatar0group, squaresm, lgnonenonefallback
badge0accent, success, warning, dangersm, lgnonenonenone
tag0accentnonenonenoneremove
kbd0nonenonenonenonenone
carousel0nonenonenonenoneviewport, slide
alert0success, warning, danger, infononenonenonetitle, actions, icon
progress0nonesm, lgnonenonenone
meter0nonesm, lgnonenonenone
spinner0nonesm, lgnonenonelabel
skeleton0text, circle, blocksm, lgnonenonenone
breadcrumb0nonenonenonenonenone
pagination0nonenonenonenoneellipsis, previous, next
steps0nonenonedata-slean-orientation: verticalnonetitle, description, complete
toggle1nonesm, lgnonedata-icon-onlynone
toggle-group1nonesm, lgdata-slean-orientation: verticalnoneitem
toolbar1nonenonedata-slean-orientation: verticalnonenone
range-slider1nonenonenonenonetrack, start, end
number-field1nonenonenonenoneinput, decrement, increment
tree1nonenonenonenonelabel
tooltip1nonenonedata-slean-side: bottom | left | rightnonetrigger, content
toast2success, warning, dangernonenonenonelist, item, title, description, action, close
button-group0soft, dangernonedata-slean-orientation: verticaldata-variantnone
scroll-area0outlinenonedata-slean-orientation: horizontalnonenone
descriptions0outlinenonedata-slean-columns: 1 | 2 | 3 | 4; data-slean-layout: inlinenonenone
timeline0info, success, warning, dangernonenonenonetitle, description, pending
empty0outlinenonenonenoneicon, title, description, actions
splitter2nonenonedata-slean-orientation: verticalnonepane, handle
context-menu1nonenonenonenonetarget, menu
file-drop1nonenonenonenoneoutput, input
hover-card1nonenonedata-slean-side: top | left | rightnonetrigger, content
menubar1nonenonenonenonenone

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.

Button variants and sizes
<button type="button" data-slean="button">Default</button>
<button type="button" data-slean="button" data-variant="secondary">Secondary</button>
<button type="button" data-slean="button" data-variant="outline">Outline</button>
<button type="button" data-slean="button" data-variant="soft">Soft</button>
<button type="button" data-slean="button" data-variant="ghost">Ghost</button>
<button type="button" data-slean="button" data-variant="danger">Danger</button>
<button type="button" data-slean="button" data-variant="link">Link</button>

<button type="button" data-slean="button" data-size="sm">Small</button>
<button type="button" data-slean="button">Medium</button>
<button type="button" data-slean="button" data-size="lg">Large</button>

<button type="button" data-slean="button" data-variant="outline" data-icon-only aria-label="Close">
	<svg aria-hidden="true" viewBox="0 0 16 16">…</svg>
</button>
<button type="button" data-slean="button" aria-busy="true">Saving</button>
<button type="button" data-slean="button" disabled>Disabled</button>
The busy button reads aria-busy and turns its spinner with the indicator duration token, which reduced motion slows instead of stopping. Disabled and aria-disabled look the same: the muted surface of every disabled control.

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.

Dialog sizes

Small

data-size="sm"

Large

data-size="lg"

<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>
Both dialogs are opened by invoker commands and closed by Escape, the backdrop or the button; the browser owns all of it.

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.

Popover side and alignment
Default: below the invoker, centered.
data-slean-side="top"
data-slean-side="right"
data-slean-align="start"
data-slean-align="end"
<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>
Side keywords are physical; alignment is logical, so under dir=rtl start follows the invoker's right edge. Open one and press Escape or click outside: light dismiss is the browser's.

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.

Vertical tabs
General preferences.
<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>
Up and Down move between triggers, Home and End jump; the selected state is aria-selected, written by the shared behavior and read by the stylesheet.

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.

PrimitiveState 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"]
comboboxnone
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"]
avatarnone
badgenone
tag:disabled,:focus-visible,:hover
kbdnone
carousel:disabled
alertnone
progress:indeterminate
meternone
spinnernone
skeletonnone
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-areanone
descriptionsnone
timelinenone
emptynone
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.

app.css
/* 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);
}
A variant of your own
<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>
Styled by the rule above in this page's own stylesheet. data-size still works, because the package's size rule does not care which variant is set.

The full files are in packages/styles/css; the conventions they follow are in packages/styles/AGENTS.md.