# Variants

> The looks model: data-variant (shape or fill), data-color (seven palette colors), data-size and the modifiers, with variant × color matrices of buttons, badges, alerts and fields.

Page: https://svelte-lean.vercel.app/styling/variants · Markdown: https://svelte-lean.vercel.app/styling/variants.md · Index: https://svelte-lean.vercel.app/llms.txt

Three attributes set how a component looks: data-variant is the shape or fill, data-color one of seven palette colors, data-size small, medium or large.

| Attribute | Values | Read by |
| --- | --- | --- |
| `data-variant` | button, toggle `contained` \| `outlined` \| `text`; fields `outlined` \| `filled` \| `standard`; badge, alert, toast `standard` \| `filled` \| `outlined`; card `elevation` \| `outlined`; progress `linear` \| `circular`; avatar `circular` \| `rounded` \| `square` | CSS |
| `data-color` | `primary`\|`secondary`\|`error`\|`warning`\|`info`\|`success`\|`neutral`, on the element or any ancestor | CSS |
| `data-size` | `small` \| `medium` (default) \| `large` | CSS |
| Modifiers | `data-full-width`, `data-icon-only`, `data-density="compact\|comfortable"` (inherited), the `start-icon` and `end-icon` parts, `disabled`, `aria-disabled`, `aria-busy`, `aria-invalid`, `data-status="warning"` | CSS |
| Options | `data-slean-side`, `data-slean-orientation`, … | CSS and the behavior |

## Button

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';

	const variants = ['contained', 'outlined', 'text'];
	const colors = ['primary', 'secondary', 'error', 'warning', 'info', 'success', 'neutral'];
</script>

{#each variants as variant (variant)}
	{#each colors as color (color)}
		<button type="button" data-slean="button" data-variant={variant} data-color={color}>
			{color}
		</button>
	{/each}
{/each}
```

Every variant is drawn once from the current color, so any color works with any variant. Neutral is MUI's inherit: outlined and text take the text color and the border hairline; contained stays a neutral fill.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
</script>

<button type="button" data-slean="button" data-size="small">Small</button>
<button type="button" data-slean="button">Medium</button>
<button type="button" data-slean="button" data-size="large">Large</button>

<button type="button" data-slean="button">
	<span data-slean-part="start-icon" aria-hidden="true"><CheckIcon /></span>
	Approve
</button>
<button type="button" data-slean="button" data-variant="outlined">
	Next
	<span data-slean-part="end-icon" aria-hidden="true"><ArrowIcon /></span>
</button>
<button type="button" data-slean="button" data-variant="text" data-color="neutral"
	data-icon-only aria-label="Close">
	<CloseIcon />
</button>

<button type="button" data-slean="button" aria-busy="true">Saving</button>
<button type="button" data-slean="button" disabled>Disabled</button>
<button type="button" data-slean="button" data-variant="outlined" aria-disabled="true">
	Not allowed
</button>

<button type="submit" data-slean="button" data-full-width>Create account</button>
```

Icons sit in the start-icon and end-icon parts; busy keeps the button focusable; aria-disabled keeps it in the tab order.

## Badge and alert

```svelte
<script lang="ts">
	import '@svelte-lean/styles/badge.css';
	import '@svelte-lean/styles/alert.css';

	const variants = ['standard', 'filled', 'outlined'];
	const colors = ['primary', 'secondary', 'error', 'warning', 'info', 'success', 'neutral'];
	const alerts = {
		success: 'Payment received',
		info: 'Invoices sync every hour',
		warning: 'Two seats left on this plan',
		error: 'The last deploy failed'
	};
</script>

{#each variants as variant (variant)}
	{#each colors as color (color)}
		<span data-slean="badge" data-variant={variant} data-color={color}>{color}</span>
	{/each}
{/each}

{#each variants as variant (variant)}
	{#each Object.entries(alerts) as [color, text] (color)}
		<div data-slean="alert" data-variant={variant} data-color={color}>
			<span data-slean-part="icon" aria-hidden="true"></span>
			<p data-slean-part="title">{text}</p>
		</div>
	{/each}
{/each}
```

Standard is the soft tint, filled the main color, outlined a line in the color. Alerts take the status icon of their color.

## Fields

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
	import '@svelte-lean/styles/select.css';
</script>

{#each ['outlined', 'filled', 'standard'] as variant (variant)}
	<div data-slean="field">
		<label for="name-{variant}" data-slean-part="label">{variant}</label>
		<input id="name-{variant}" data-slean="input" data-variant={variant} placeholder="Name" />
	</div>
	<div data-slean="field">
		<label for="role-{variant}" data-slean-part="label">Role</label>
		<select id="role-{variant}" data-slean="select" data-variant={variant}>
			<option>Admin</option>
			<option>Member</option>
		</select>
	</div>
	<div data-slean="field">
		<label for="price-{variant}" data-slean-part="label">Price</label>
		<div data-slean="input" data-variant={variant}>
			<span data-slean-part="prefix">$</span>
			<input id="price-{variant}" inputmode="decimal" value="120" />
			<span data-slean-part="suffix">USD</span>
		</div>
	</div>
{/each}
```

The bare input, the bare native select and the input shell share the three shells; focus draws from the current color.

```svelte
<!-- Sizes line up with the button of the same size. -->
{#each ['small', 'medium', 'large'] as size (size)}
	<input data-slean="input" data-size={size} aria-label="Search, {size}" placeholder={size} />
	<select data-slean="select" data-size={size} aria-label="Status, {size}">
		<option>Open</option>
	</select>
	<button type="button" data-slean="button" data-size={size}>Search</button>
{/each}

<!-- An error is aria-invalid, a warning data-status, disabled the platform's. -->
<div data-slean="field">
	<label for="email" data-slean-part="label">Email</label>
	<input id="email" data-slean="input" aria-invalid="true" aria-describedby="email-error"
		value="ada@" />
	<p id="email-error" data-slean-part="error">Enter a complete address.</p>
</div>
<div data-slean="field">
	<label for="domain" data-slean-part="label">Domain</label>
	<input id="domain" data-slean="input" data-status="warning" aria-describedby="domain-hint"
		value="gmial.com" />
	<p id="domain-hint" data-slean-part="description">Did you mean gmail.com?</p>
</div>
<div data-slean="field">
	<label for="workspace" data-slean-part="label">Workspace</label>
	<input id="workspace" data-slean="input" value="harbor" disabled />
</div>
```

A field of a size is as tall as the button of that size. Error, warning and disabled come from aria-invalid, data-status and disabled.

## Density

```svelte
<!-- data-density on any element changes the control heights of its subtree. -->
{#each ['compact', 'comfortable'] as density (density)}
	<form data-density={density}>
		<input data-slean="input" aria-label="Name" placeholder="Name" />
		<select data-slean="select" aria-label="Role"><option>Member</option></select>
		<button type="submit" data-slean="button">Invite</button>
	</form>
{/each}
```

data-density on a region changes the control heights of everything inside it.

## Surfaces and indicators

```svelte
<script lang="ts">
	import '@svelte-lean/styles/card.css';
	import '@svelte-lean/styles/avatar.css';
	import '@svelte-lean/styles/progress.css';
	import '@svelte-lean/styles/skeleton.css';
</script>

<article data-slean="card"><p>Elevation (default)</p></article>
<article data-slean="card" data-variant="outlined"><p>Outlined</p></article>

{#each ['circular', 'rounded', 'square'] as variant (variant)}
	<span data-slean="avatar" data-variant={variant} role="img" aria-label="Ada Lovelace">
		<span data-slean-part="fallback" aria-hidden="true">AL</span>
	</span>
{/each}

{#each ['primary', 'success', 'warning', 'error'] as color (color)}
	<progress data-slean="progress" data-color={color} value="60" max="100"
		aria-label="{color} progress"></progress>
	<progress data-slean="progress" data-variant="circular" data-color={color}
		aria-label="Loading, {color}"></progress>
{/each}

{#each ['text', 'rectangular', 'rounded', 'circular'] as variant (variant)}
	<span data-slean="skeleton" data-variant={variant}></span>
{/each}
```

A card is elevated by default; outlined trades the shadow for a border. Progress takes the current color.

## Overlays and orientation

`data-size` sets the inline size. Open state is `[open]`, the entrance is `@starting-style`: no JavaScript.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/dialog.css';
</script>

<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" commandfor="dialog-sm" command="show-modal">
	Small dialog
</button>
<dialog id="dialog-sm" data-slean="dialog" data-size="small" aria-labelledby="dialog-sm-title">
	<header>
		<h2 id="dialog-sm-title">Small</h2>
		<p>data-size="small"</p>
	</header>
	<footer>
		<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" commandfor="dialog-sm" command="close">
			Close
		</button>
	</footer>
</dialog>

<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" commandfor="dialog-lg" command="show-modal">
	Large dialog
</button>
<dialog id="dialog-lg" data-slean="dialog" data-size="large" aria-labelledby="dialog-lg-title">
	<header>
		<h2 id="dialog-lg-title">Large</h2>
		<p>data-size="large"</p>
	</header>
	<footer>
		<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" commandfor="dialog-lg" command="close">
			Close
		</button>
	</footer>
</dialog>
```

Invoker commands open them; Escape, the backdrop or the button close them. The browser owns all of it.

`data-slean-side` and `data-slean-align` place the popover against its invoker with `position-area` and flip fallbacks; without anchor positioning it stays centered in the top layer.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/popover.css';
</script>

<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" 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="outlined" data-color="neutral" 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="outlined" data-color="neutral" 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="outlined" data-color="neutral" 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="outlined" data-color="neutral" popovertarget="pop-end">Bottom, end</button>
<div id="pop-end" popover data-slean="popover" data-slean-align="end">data-slean-align="end"</div>
```

Side is physical; alignment is logical, so under dir=rtl start follows the invoker's right edge. Light dismiss is the browser's.

```svelte
<script lang="ts">
	// @svelte-lean/vite injects the behavior for the static data-slean="tabs" marker.
	import '@svelte-lean/styles/tabs.css';
	import { tabsParts } from '@svelte-lean/primitives/tabs';

	const t = tabsParts('prefs', { orientation: 'vertical' });
</script>

<div id="prefs" data-slean="tabs" data-slean-value="general" data-slean-orientation="vertical">
	<div {...t.list} aria-label="Preferences">
		<button {...t.tab('general', 'general')}>General</button>
		<button {...t.tab('privacy', 'general')}>Privacy</button>
	</div>
	<div {...t.panel('general', 'general')} tabindex="0">General preferences.</div>
	<div {...t.panel('privacy', 'general')} tabindex="0">Privacy preferences.</div>
</div>
```

data-slean-orientation="vertical": Up and Down move, Home and End jump; the indicator moves to the inline-end edge.

## A color of your own

A variant is a shape, so a brand look is a color: one unlayered rule sets the six tokens of the current color, and every variant, size and state of the package draws from them.

`app.css`

```css
/* A color the package does not know: an unlayered rule that sets the six tokens of the current
 * color. Every variant of every primitive draws from them (the outlined ink and line follow the
 * main tone by themselves), so data-color="brand" works on a button, a badge, an alert, a field or
 * a whole region. */
[data-color='brand'] {
	--slean-color-main: oklch(45% 0.12 300);
	--slean-color-light: oklch(60% 0.1 300);
	--slean-color-dark: oklch(40% 0.12 300);
	--slean-color-contrast-text: oklch(100% 0 0);
	--slean-color-soft: oklch(95% 0.03 300);
	--slean-color-soft-text: oklch(40% 0.12 300);
}
/* Its dark tones, under the package's two dark conditions. */
@media (prefers-color-scheme: dark) {
	:root:not([data-theme='light'], .light) [data-color='brand'] {
		--slean-color-main: oklch(72% 0.12 300);
		--slean-color-dark: oklch(65% 0.12 300);
		--slean-color-contrast-text: oklch(18% 0.02 300);
		--slean-color-soft: oklch(28% 0.06 300);
		--slean-color-soft-text: oklch(86% 0.07 300);
	}
}
:is([data-theme='dark'], .dark) [data-color='brand'] {
	--slean-color-main: oklch(72% 0.12 300);
	--slean-color-dark: oklch(65% 0.12 300);
	--slean-color-contrast-text: oklch(18% 0.02 300);
	--slean-color-soft: oklch(28% 0.06 300);
	--slean-color-soft-text: oklch(86% 0.07 300);
}
```

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import './app.css'; // the [data-color='brand'] rules above
</script>

<button type="button" data-slean="button" data-color="brand">Brand</button>
<button type="button" data-slean="button" data-color="brand" data-variant="outlined">Brand, outlined</button>
<button type="button" data-slean="button" data-color="brand" data-size="small">Brand, small</button>
```

Styled by the rule above; every variant and size still works.

Source: packages/styles/css, conventions in packages/styles/AGENTS.md.

## Reference

| Primitive | Tier | data-variant | data-size | Options | Modifiers | Parts |
| --- | --- | --- | --- | --- | --- | --- |
| `button` | 0 | outlined, text | small, large | none | data-full-width, data-icon-only | start-icon, end-icon |
| `dialog` | 0 | none | small, large | data-slean-side: right \| left \| top \| bottom | data-size | header, title, description, footer, close |
| `popover` | 0 | none | none | data-slean-side: top \| left \| right; data-slean-align: start \| end | none | header, title, description, footer |
| `checkbox` | 0 | none | small, large | none | none | none |
| `checkbox-group` | 1 | none | none | data-slean-orientation: horizontal | none | all |
| `radio-group` | 0 | segmented, swatch | small, large | data-slean-orientation: horizontal | data-full-width | none |
| `tabs` | 1 | none | small, large | data-slean-orientation: vertical | data-full-width | list, trigger, panel |
| `menu` | 1 | none | none | none | data-color | group-label |
| `listbox` | 1 | none | none | data-slean-orientation: horizontal | none | list, group-label |
| `control` | – | filled, standard | small, large | none | none | input, trigger, native, prefix, suffix, clear, separator, popup, group-label, empty |
| `calendar` | 1 | none | none | data-slean-mode: range; data-slean-edge: start \| end \| both | none | grid, header, title, month, year, previous, next, previous-year, next-year, day |
| `date-picker` | 2 | none | none | none | none | separator, input, clear, popup, footer, preset, presets, time, today, native |
| `input` | 1 | filled, standard | small, large | data-slean-length: 4 \| 8 | data-full-width | prefix, suffix, clear, reveal, decrement, increment |
| `field` | 0 | none | none | data-slean-orientation: horizontal | none | label, description, error |
| `select` | 2 | filled, standard | small, large | data-slean-render: app | data-full-width | native, trigger, value, placeholder, tag, remove, prefix, suffix, clear, popup |
| `rating` | 0 | none | small, large | none | none | none |
| `slider` | 1 | native | none | data-slean-orientation: vertical | none | none |
| `autocomplete` | 2 | filled, standard | small, large | none | data-full-width | clear, popup |
| `accordion` | 0 | ghost | none | none | none | panel |
| `card` | 0 | outlined | none | none | none | media, header, title, description, body, footer, link |
| `separator` | 0 | none | none | none | none | none |
| `avatar` | 0 | rounded, square | small, large | none | data-color | fallback |
| `badge` | 0 | filled, outlined | small, large | none | none | remove |
| `kbd` | 0 | none | none | none | none | none |
| `carousel` | 0 | none | none | none | none | viewport, slide |
| `alert` | 0 | filled, outlined | none | none | none | title, actions, icon, close |
| `progress` | 0 | circular | small, large | none | none | none |
| `meter` | 0 | none | small, large | none | none | none |
| `skeleton` | 0 | text, circular, rectangular, rounded | small, large | none | none | none |
| `breadcrumb` | 0 | none | none | none | none | none |
| `nav` | 0 | none | none | data-slean-orientation: horizontal | none | group-label, icon, actions |
| `pagination` | 0 | none | small, large | none | none | ellipsis, previous, next |
| `steps` | 0 | none | none | data-slean-orientation: vertical | none | title, description, complete, error |
| `toggle` | 1 | text, outlined | small, large | none | data-icon-only | start-icon, end-icon |
| `toggle-group` | 1 | text, outlined | small, large | data-slean-orientation: vertical | none | item |
| `toolbar` | 1 | none | none | data-slean-orientation: vertical | none | none |
| `tree` | 1 | none | none | none | none | label |
| `toast` | 2 | filled, outlined | none | data-slean-side: top; data-slean-align: start \| center | data-color | list, item, title, description, actions, close |
| `button-group` | 0 | contained | none | data-slean-orientation: vertical | data-variant | none |
| `scroll-area` | 0 | outlined | none | data-slean-orientation: horizontal | none | none |
| `descriptions` | 0 | outlined | none | data-slean-columns: 1 \| 2 \| 3 \| 4; data-slean-layout: inline | none | none |
| `timeline` | 0 | none | none | none | data-color | title, description, pending |
| `empty` | 0 | outlined | none | none | none | icon, title, description, actions |
| `splitter` | 2 | none | none | data-slean-orientation: vertical | none | pane, handle |
| `sortable` | 2 | none | none | data-slean-orientation: horizontal | none | handle, previous, next |
| `file-drop` | 1 | picture-card, picture-circle | none | none | none | area, icon, output, list, input, item, media, title, description, remove |
| `hover-card` | 1 | none | none | data-slean-side: top \| left \| right | none | none |
| `menubar` | 1 | none | none | none | none | none |
| `hint` | 1 | none | none | none | none | none |
| `copy` | 1 | none | none | none | none | icon |
| `findable` | 1 | none | none | none | none | full |

Generated: tiers from the primitives metadata, the rest from `packages/styles/css/<name>.css`.

## State selectors

State comes from the platform and ARIA (`:checked`, `[open]`, `aria-selected`), never a mirrored `data-state`, so server-rendered markup is styled correctly before any script runs.

| Primitive | State selectors used |
| --- | --- |
| `button` | `:disabled`,`:hover`,`:active`,`[aria-disabled="true"]`,`[aria-busy="true"]` |
| `dialog` | `:hover`,`:focus-visible`,`[open]` |
| `popover` | `:popover-open` |
| `checkbox` | `:checked`,`:indeterminate`,`:disabled`,`:user-invalid`,`:hover`,`[aria-invalid="true"]` |
| `checkbox-group` | `:disabled` |
| `radio-group` | `:checked`,`:disabled`,`:user-invalid`,`:focus-visible`,`:hover`,`[aria-invalid="true"]` |
| `tabs` | `:hover`,`:disabled`,`:focus-visible`,`[aria-disabled="true"]`,`[aria-selected="true"]` |
| `menu` | `:hover`,`:disabled`,`:focus-visible`,`[aria-disabled="true"]`,`[aria-checked]`,`[aria-checked="true"]` |
| `listbox` | `:hover`,`:focus-visible`,`[aria-disabled="true"]`,`[aria-selected="true"]` |
| `control` | `:focus-within`,`:user-invalid`,`:hover`,`:focus-visible`,`:disabled`,`:placeholder-shown`,`:checked`,`:popover-open`,`[aria-invalid="true"]`,`[aria-expanded="true"]`,`[aria-disabled="true"]` |
| `calendar` | `:hover`,`[aria-disabled="true"]`,`[aria-current="date"]`,`[aria-selected="true"]` |
| `date-picker` | `:hover`,`:placeholder-shown`,`:disabled`,`:focus-within`,`:popover-open`,`[aria-expanded="true"]`,`[aria-pressed="true"]` |
| `input` | `:placeholder-shown`,`:disabled`,`:focus-visible`,`:user-invalid`,`:hover`,`:focus-within`,`[aria-invalid="true"]`,`[aria-pressed="true"]` |
| `field` | `:user-invalid`,`:disabled`,`[aria-invalid="true"]` |
| `select` | `:disabled`,`:user-invalid`,`:open`,`:checked`,`:hover`,`:focus-visible`,`[aria-invalid="true"]` |
| `rating` | `:checked`,`:disabled`,`:hover`,`:focus-visible` |
| `slider` | `:disabled`,`:focus-visible`,`:hover` |
| `autocomplete` | `:focus-visible`,`:user-invalid`,`:disabled`,`:hover`,`[aria-invalid="true"]` |
| `accordion` | `:focus-visible`,`:hover`,`[open]` |
| `card` | `:focus-visible`,`:hover` |
| `separator` | `[aria-orientation="vertical"]` |
| `avatar` | none |
| `badge` | `:disabled`,`:focus-visible`,`:hover` |
| `kbd` | none |
| `carousel` | `:disabled` |
| `alert` | `:active`,`:hover`,`:focus-visible` |
| `progress` | `:indeterminate` |
| `meter` | none |
| `skeleton` | none |
| `breadcrumb` | `:hover`,`:focus-visible`,`[aria-current="page"]` |
| `nav` | `:hover`,`:focus-visible`,`[open]`,`[aria-current="page"]`,`[aria-disabled="true"]` |
| `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"]` |
| `tree` | `:focus-visible`,`:hover`,`[aria-expanded="false"]`,`[aria-expanded]`,`[aria-expanded="true"]`,`[aria-disabled="true"]`,`[aria-selected="true"]` |
| `toast` | `:active`,`:focus-visible`,`: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"]` |
| `sortable` | `:disabled`,`:hover`,`:focus-visible`,`[aria-disabled="true"]` |
| `file-drop` | `:focus-visible`,`:disabled`,`:user-invalid`,`:hover`,`[aria-busy="true"]` |
| `hover-card` | `:popover-open` |
| `menubar` | `:popover-open`,`:focus-visible`,`:hover`,`[aria-disabled="true"]` |
| `hint` | none |
| `copy` | `:disabled`,`[aria-disabled="true"]` |
| `findable` | none |
