Skip to content

UI State Examples

The examples below assume that you have imported the component and set up the theme. sv-ui-state evaluates its expressions with sv-expression-parser, so import that one too.

Basic

A <sv-store> inside <sv-ui-state> declares a named value. bind-text reads it and bind-event writes it, and calling a store with an argument is how it is set.

<sv-ui-state>
<sv-store type="number" name="$count" value="0"></sv-store>
<div class="basic-counter">
<button type="button" bind-event="click:$count($count - 1)">-</button>
<strong bind-text="$count"></strong>
<button type="button" bind-event="click:$count($count + 1)">+</button>
</div>
</sv-ui-state>
<style>
.basic-counter {
display: flex;
gap: 0.75em;
align-items: center;
justify-content: center;
padding-block: 1em;
button {
inline-size: 2em;
block-size: 2em;
font: inherit;
color: var(--sv-gray-1);
cursor: pointer;
background-color: var(--sv-gray-5);
border: none;
border-radius: var(--sv-bdr-rad-sm);
}
strong {
min-inline-size: 2em;
font-size: 1.2em;
text-align: center;
}
}
</style>

Store Types

type decides how the value attribute is parsed at startup. The first two stores below both start from the string 41, and only the one typed as a number adds to it.

$label + 1

type="string"

$build + 1

type="number"

!$draft

type="boolean"

$author.team

type="json"
<sv-ui-state>
<sv-store name="$label" value="41"></sv-store>
<sv-store type="number" name="$build" value="41"></sv-store>
<sv-store type="boolean" name="$draft" value="true"></sv-store>
<sv-store type="json" name="$author" value='{"name":"Alice","team":"Docs"}'></sv-store>
<div class="types-rows">
<div class="variant">
<p>
<code>$label + 1</code>
<strong bind-text="$label + 1"></strong>
</p>
<span>type="string"</span>
</div>
<div class="variant">
<p>
<code>$build + 1</code>
<strong bind-text="$build + 1"></strong>
</p>
<span>type="number"</span>
</div>
<div class="variant">
<p>
<code>!$draft</code>
<strong bind-text="!$draft"></strong>
</p>
<span>type="boolean"</span>
</div>
<div class="variant">
<p>
<code>$author.team</code>
<strong bind-text="$author.team"></strong>
</p>
<span>type="json"</span>
</div>
</div>
</sv-ui-state>
<style>
.types-rows {
display: grid;
gap: 1.25em;
justify-items: center;
padding-block: 1em;
.variant {
display: grid;
gap: 0.4em;
justify-items: center;
> span {
font-size: 0.8em;
color: var(--sv-gray-3);
}
}
p {
display: flex;
gap: 0.75em;
align-items: center;
margin: 0;
}
code {
padding: 0.2em 0.6em;
font-family: monospace;
font-size: 0.9em;
color: var(--sv-gray-3);
background-color: var(--sv-gray-6);
border-radius: var(--sv-bdr-rad-sm);
}
}
</style>

Text and Attributes

bind-text takes an expression, not only a store name. bind-attr writes the result into an attribute, and bind-attr-toggle adds or removes a boolean attribute from a condition.

<sv-ui-state>
<sv-store type="number" name="$step" value="1"></sv-store>
<div class="steps">
<p class="steps-label" bind-text="'Step ' + $step + ' of 3'"></p>
<progress class="steps-bar" bind-attr="value:$step" max="3"></progress>
<div class="steps-actions">
<button type="button" bind-attr-toggle="disabled:$step === 1" bind-event="click:$step($step - 1)">Back</button>
<button type="button" bind-attr-toggle="disabled:$step === 3" bind-event="click:$step($step + 1)">Next</button>
</div>
</div>
</sv-ui-state>
<style>
.steps {
display: grid;
gap: 0.75em;
justify-items: center;
padding-block: 1em;
.steps-label {
margin: 0;
font-size: 0.9em;
color: var(--sv-gray-3);
}
.steps-bar {
inline-size: 14rem;
accent-color: var(--sv-accent);
}
.steps-actions {
display: flex;
gap: 0.4em;
button {
padding: 0.3em 1em;
font: inherit;
font-size: 0.85em;
color: var(--sv-gray-1);
cursor: pointer;
background-color: var(--sv-gray-5);
border: none;
border-radius: var(--sv-bdr-rad-sm);
&:disabled {
color: var(--sv-gray-4);
cursor: not-allowed;
}
}
}
}
</style>

Form Inputs

bind-prop sets a DOM property, which is what value and checked need. Paired with bind-event the input writes to the store and the store writes back to the input. The reset button changes only the stores, and both fields follow.

<sv-ui-state>
<sv-store name="$name" value="Alice"></sv-store>
<sv-store type="boolean" name="$updates" value="true"></sv-store>
<div class="inputs-form">
<label class="inputs-field">
Name
<input type="text" bind-event="input:$name(EVENT.target.value)" bind-prop="value:$name" />
</label>
<label class="inputs-check">
<input type="checkbox" bind-event="change:$updates(EVENT.target.checked)" bind-prop="checked:$updates" />
Send me updates
</label>
<p class="inputs-readout" bind-text="$name + ($updates ? ' gets updates' : ' gets nothing')"></p>
<button type="button" bind-event="click:$name('Alice'), $updates(true)">Reset</button>
</div>
</sv-ui-state>
<style>
.inputs-form {
display: grid;
gap: 0.75em;
justify-items: center;
padding-block: 1em;
.inputs-field {
display: grid;
gap: 0.3em;
font-size: 0.85em;
color: var(--sv-gray-3);
input {
inline-size: 14rem;
padding: 0.4em 0.6em;
font: inherit;
color: var(--sv-gray-0);
background-color: var(--sv-gray-6);
border: var(--sv-bdr-sz-sm) solid var(--sv-gray-5);
border-radius: var(--sv-bdr-rad-sm);
}
}
.inputs-check {
display: flex;
gap: 0.4em;
align-items: center;
font-size: 0.85em;
color: var(--sv-gray-3);
accent-color: var(--sv-accent);
}
.inputs-readout {
margin: 0;
font-size: 0.9em;
}
button {
padding: 0.3em 1em;
font: inherit;
font-size: 0.85em;
color: var(--sv-gray-1);
cursor: pointer;
background-color: var(--sv-gray-5);
border: none;
border-radius: var(--sv-bdr-rad-sm);
}
}
</style>

Classes

bind-class sets a class name from the value itself and removes the one it set before. bind-class-toggle adds or removes a fixed class from a condition, one class per pair.

<sv-ui-state>
<sv-store name="$status" value="operational"></sv-store>
<div class="status-demo">
<span class="status-badge" bind-class="$status" bind-text="$status"></span>
<div class="status-picker">
<button type="button" bind-class-toggle="is-active:$status === 'operational'" bind-event="click:$status('operational')">
Operational
</button>
<button type="button" bind-class-toggle="is-active:$status === 'degraded'" bind-event="click:$status('degraded')">
Degraded
</button>
<button type="button" bind-class-toggle="is-active:$status === 'down'" bind-event="click:$status('down')">Down</button>
</div>
</div>
</sv-ui-state>
<style>
.status-demo {
display: grid;
gap: 0.75em;
justify-items: center;
padding-block: 1em;
.status-badge {
padding: 0.25em 0.9em;
font-size: 0.85em;
background-color: var(--sv-gray-6);
border-radius: 999px;
&.operational {
color: var(--sv-success);
}
&.degraded {
color: var(--sv-warning);
}
&.down {
color: var(--sv-error);
}
}
.status-picker {
display: flex;
gap: 0.4em;
button {
padding: 0.3em 0.9em;
font: inherit;
font-size: 0.85em;
color: var(--sv-gray-3);
cursor: pointer;
background-color: var(--sv-gray-6);
border: var(--sv-bdr-sz-sm) solid transparent;
border-radius: var(--sv-bdr-rad-sm);
&.is-active {
color: var(--sv-gray-0);
border-color: var(--sv-accent);
}
}
}
}
</style>

Styles

bind-style sets a CSS property on the element. Custom properties work too, so state can reach styles that a class cannot express.

<sv-ui-state>
<sv-store type="number" name="$size" value="80"></sv-store>
<sv-store type="number" name="$hue" value="200"></sv-store>
<div class="style-demo">
<div class="style-box" bind-style="--box-size:$size + 'px'; --box-hue:$hue"></div>
<label class="style-control">
Size
<input type="range" bind-event="input.passive:$size(+EVENT.target.value)" bind-prop="value:$size" max="140" min="40" />
</label>
<label class="style-control">
Hue
<input type="range" bind-event="input.passive:$hue(+EVENT.target.value)" bind-prop="value:$hue" max="360" min="0" />
</label>
</div>
</sv-ui-state>
<style>
.style-demo {
display: grid;
gap: 0.75em;
justify-items: center;
padding-block: 1em;
.style-box {
inline-size: var(--box-size, 80px);
block-size: var(--box-size, 80px);
background-color: oklch(65% 0.14 var(--box-hue, 200));
border-radius: var(--sv-bdr-rad-md);
}
.style-control {
display: flex;
gap: 0.6em;
align-items: center;
font-size: 0.85em;
color: var(--sv-gray-3);
input {
inline-size: 10rem;
accent-color: var(--sv-accent);
}
}
}
</style>

Show and Hide

bind-show sets display: none while the expression is falsy and restores the previous display when it turns truthy. The element stays in the DOM either way.

Plan
Team
Seats
8
Renews
March 1
<sv-ui-state>
<sv-store type="boolean" name="$details" value="false"></sv-store>
<div class="show-demo">
<button
type="button"
bind-attr="aria-expanded:$details"
bind-event="click:$details(!$details)"
bind-text="$details ? 'Hide details' : 'Show details'"
></button>
<dl class="show-panel" bind-show="$details">
<dt>Plan</dt>
<dd>Team</dd>
<dt>Seats</dt>
<dd>8</dd>
<dt>Renews</dt>
<dd>March 1</dd>
</dl>
</div>
</sv-ui-state>
<style>
.show-demo {
display: grid;
gap: 0.75em;
justify-items: center;
padding-block: 1em;
button {
padding: 0.3em 1em;
font: inherit;
font-size: 0.85em;
color: var(--sv-gray-1);
cursor: pointer;
background-color: var(--sv-gray-5);
border: none;
border-radius: var(--sv-bdr-rad-sm);
}
.show-panel {
display: grid;
grid-template-columns: auto auto;
gap: 0.3em 1.5em;
min-inline-size: 12rem;
padding: 0.8em 1.2em;
margin: 0;
font-size: 0.85em;
background-color: var(--sv-gray-6);
border-radius: var(--sv-bdr-rad-md);
dt {
color: var(--sv-gray-3);
}
dd {
margin: 0;
text-align: end;
}
}
}
</style>

Events

bind-event runs an expression when the event fires, and EVENT is the native event object. Separate several events with ;, like input and keydown on the textarea, where EVENT.key picks out Escape.

The event name takes modifiers: prevent, stop, once, capture, and passive. submit.prevent keeps the page from reloading, and a comma chains two updates to post the reply and clear the draft.

<sv-ui-state>
<sv-store name="$draft" value=""></sv-store>
<sv-store name="$reply" value=""></sv-store>
<div class="events-demo">
<p class="events-reply" bind-show="$reply" bind-text="$reply"></p>
<form class="events-form" bind-event="submit.prevent:$reply($draft), $draft('')">
<textarea
aria-label="Reply"
bind-event="input:$draft(EVENT.target.value); keydown:EVENT.key === 'Escape' && $draft('')"
bind-prop="value:$draft"
placeholder="Write a reply"
rows="2"
></textarea>
<div class="events-footer">
<span>Esc clears</span>
<button type="submit" bind-attr-toggle="disabled:!$draft">Reply</button>
</div>
</form>
</div>
</sv-ui-state>
<style>
.events-demo {
display: grid;
gap: 0.75em;
justify-items: center;
padding-block: 1em;
.events-reply {
max-inline-size: 18rem;
padding: 0.5em 0.8em;
margin: 0;
font-size: 0.85em;
background-color: var(--sv-gray-6);
border-radius: var(--sv-bdr-rad-md);
}
.events-form {
display: grid;
gap: 0.4em;
inline-size: min(100%, 18rem);
}
textarea {
padding: 0.4em 0.6em;
font: inherit;
font-size: 0.85em;
color: var(--sv-gray-0);
resize: none;
background-color: var(--sv-gray-6);
border: var(--sv-bdr-sz-sm) solid var(--sv-gray-5);
border-radius: var(--sv-bdr-rad-sm);
}
.events-footer {
display: flex;
align-items: center;
justify-content: space-between;
span {
font-size: 0.75em;
color: var(--sv-gray-3);
}
}
button {
padding: 0.3em 1em;
font: inherit;
font-size: 0.85em;
color: var(--sv-gray-1);
cursor: pointer;
background-color: var(--sv-gray-5);
border: none;
border-radius: var(--sv-bdr-rad-sm);
&:disabled {
color: var(--sv-gray-4);
cursor: not-allowed;
}
}
}
</style>

Derived Stores

A store with derive holds the result of its expression and recomputes whenever one of the stores it reads changes. $unit follows the seat count, $total follows both, and neither is ever set by hand.

<sv-ui-state>
<sv-store type="number" name="$seats" value="3"></sv-store>
<sv-store derive="$seats >= 5 ? 9 : 12" name="$unit"></sv-store>
<sv-store derive="$seats * $unit" name="$total"></sv-store>
<div class="derive-demo">
<div class="derive-seats">
<button type="button" bind-attr-toggle="disabled:$seats === 1" bind-event="click:$seats($seats - 1)">-</button>
<strong bind-text="$seats + ' seats'"></strong>
<button type="button" bind-event="click:$seats($seats + 1)">+</button>
</div>
<p class="derive-total" bind-text="$seats + ' × $' + $unit + ' = $' + $total"></p>
<p class="derive-hint" bind-text="$unit === 9 ? 'Team rate applied' : 'Team rate starts at 5 seats'"></p>
</div>
</sv-ui-state>
<style>
.derive-demo {
display: grid;
gap: 0.6em;
justify-items: center;
padding-block: 1em;
.derive-seats {
display: flex;
gap: 0.75em;
align-items: center;
button {
inline-size: 2em;
block-size: 2em;
font: inherit;
color: var(--sv-gray-1);
cursor: pointer;
background-color: var(--sv-gray-5);
border: none;
border-radius: var(--sv-bdr-rad-sm);
&:disabled {
color: var(--sv-gray-4);
cursor: not-allowed;
}
}
strong {
min-inline-size: 5em;
text-align: center;
}
}
.derive-total {
margin: 0;
font-size: 1.1em;
}
.derive-hint {
margin: 0;
font-size: 0.8em;
color: var(--sv-gray-3);
}
}
</style>

Persistent Stores

persistent saves the store on every change and reads it back on the next page load. It defaults to localStorage, and persistent="sessionStorage" keeps the value to the tab instead. The id of the sv-ui-state is part of the storage key, so give it one and keep it stable. Change the controls below, then reload the page.

Weekly digest

Three new comments and one release since Monday.

<sv-ui-state id="ui-state-preferences">
<sv-store type="number" name="$scale" persistent value="100"></sv-store>
<sv-store type="boolean" name="$compact" persistent="sessionStorage" value="false"></sv-store>
<div class="prefs">
<div class="prefs-preview" bind-class-toggle="is-compact:$compact" bind-style="--prefs-scale:$scale + '%'">
<p class="prefs-title">Weekly digest</p>
<p class="prefs-text">Three new comments and one release since Monday.</p>
</div>
<label class="prefs-control">
Text size
<input
type="range"
bind-event="input.passive:$scale(+EVENT.target.value)"
bind-prop="value:$scale"
max="140"
min="80"
step="10"
/>
<span bind-text="$scale + '%'"></span>
</label>
<label class="prefs-control">
<input type="checkbox" bind-event="change:$compact(EVENT.target.checked)" bind-prop="checked:$compact" />
Compact
</label>
</div>
</sv-ui-state>
<style>
.prefs {
display: grid;
gap: 0.75em;
justify-items: center;
padding-block: 1em;
.prefs-preview {
inline-size: 16rem;
padding: 1em 1.2em;
font-size: var(--prefs-scale, 100%);
background-color: var(--sv-gray-6);
border-radius: var(--sv-bdr-rad-md);
&.is-compact {
padding: 0.5em 1.2em;
}
}
.prefs-title {
margin: 0 0 0.3em;
font-size: 0.95em;
font-weight: 600;
}
.prefs-text {
margin: 0;
font-size: 0.85em;
color: var(--sv-gray-3);
}
.is-compact .prefs-text {
display: none;
}
.prefs-control {
display: flex;
gap: 0.6em;
align-items: center;
font-size: 0.85em;
color: var(--sv-gray-3);
accent-color: var(--sv-accent);
input[type="range"] {
inline-size: 9rem;
}
}
}
</style>

Scopes

Stores in a global component are visible page-wide. Every other component keeps its stores to its own descendants, and a nested component can declare the same name to shadow the outer store. Both panels below have their own $open, and the inner one reads the global theme all the same.

The inner scope has its own $open, so the outer one stays untouched.

<sv-ui-state global>
<sv-store name="$theme" value="light"></sv-store>
</sv-ui-state>
<div class="scopes-demo">
<div class="scopes-global" bind-class="$theme">
<span bind-text="'Global theme: ' + $theme"></span>
<button type="button" bind-event="click:$theme($theme === 'light' ? 'dark' : 'light')">Toggle</button>
</div>
<sv-ui-state>
<sv-store type="boolean" name="$open" value="false"></sv-store>
<div class="scopes-panel">
<button type="button" bind-event="click:$open(!$open)" bind-text="$open ? 'Close outer' : 'Open outer'"></button>
<div class="scopes-body" bind-show="$open">
<p bind-text="'This panel still reads the global theme: ' + $theme"></p>
<sv-ui-state>
<sv-store type="boolean" name="$open" value="false"></sv-store>
<div class="scopes-panel">
<button type="button" bind-event="click:$open(!$open)" bind-text="$open ? 'Close inner' : 'Open inner'"></button>
<p class="scopes-body" bind-show="$open">The inner scope has its own $open, so the outer one stays untouched.</p>
</div>
</sv-ui-state>
</div>
</div>
</sv-ui-state>
</div>
<style>
.scopes-demo {
display: grid;
gap: 0.75em;
inline-size: min(22rem, 100%);
padding-block: 1em;
margin-inline: auto;
.scopes-global {
display: flex;
gap: 0.75em;
align-items: center;
justify-content: space-between;
padding: 0.6em 0.9em;
font-size: 0.85em;
border-radius: var(--sv-bdr-rad-md);
&.light {
color: oklch(30% 0.02 250deg);
background-color: oklch(93% 0.01 250deg);
}
&.dark {
color: oklch(93% 0.01 250deg);
background-color: oklch(30% 0.02 250deg);
}
}
.scopes-panel {
display: grid;
gap: 0.5em;
justify-items: start;
}
.scopes-body {
padding: 0.6em 0.9em;
margin: 0;
font-size: 0.85em;
color: var(--sv-gray-3);
background-color: var(--sv-gray-6);
border-radius: var(--sv-bdr-rad-md);
}
button {
padding: 0.3em 0.9em;
font: inherit;
font-size: 0.85em;
color: var(--sv-gray-1);
cursor: pointer;
background-color: var(--sv-gray-5);
border: none;
border-radius: var(--sv-bdr-rad-sm);
}
}
</style>