States
A state is a style that applies only while the element is hovered, focused, pressed, checked, invalid and so on. WovenUI writes states in two ways: a prefix on a utility class, or a modifier after an item in apply(). Classes cover the common interactive states for a set of families; apply() covers 22 states for every mixin.
How states work
Section titled “How states work”- Class form. A state prefix and a colon go before the class name:
hover:bg-brand,focus:border-brand,group-hover:text-brand. - apply() form. The state goes after the item, separated by a space:
bg(brand) hover,border-color(brand) focus,text-color(brand) group-hover. - Same CSS. Both compile to a pseudo-class rule on the element.
group-hoveris the exception: it compiles to a descendant rule under.group:hover.
<a class="hover:bg-brand">Pricing</a>.hover\:bg-brand:hover,.group:hover .group-hover\:bg-brand { background-color: var(--wu-brand);}.nav-link { @include apply(bg(brand) hover);}.nav-link:hover { background-color: var(--wu-brand);}Hover classes exist for background, text and border colors, border widths and styles, width and height, opacity, shadows and transforms. Pair them with a transition class so the change animates.
<div class="flex wrap gap-4 p-4 bg-alternate rounded"> <div class="p-3 bg-surface rounded transition-colors hover:bg-brand-subtle">hover:bg-brand-subtle</div> <div class="p-3 bg-surface rounded text-regular transition-colors hover:text-brand">hover:text-brand</div> <div class="p-3 bg-surface rounded shadow-sm transition-shadow hover:shadow-md">hover:shadow-md</div> <div class="p-3 bg-surface rounded opacity-75 transition-opacity hover:opacity-100">hover:opacity-100</div> <div class="p-3 bg-surface rounded transition-transform hover:-translate-y-1">hover:-translate-y-1</div></div>.tile { @include apply(transition, bg(surface), shadow(sm), bg(brand-subtle) hover, text-color(brand) hover, shadow(md) hover, translate-y(-1) hover);}.tile--faded { @include apply(opacity(75), opacity(100) hover);}.tile { transition-property: all; transition-duration: var(--wu-duration-200); transition-timing-function: var(--wu-ease-in-out); background-color: var(--wu-bg-surface); box-shadow: var(--wu-shadow-sm);}.tile:hover { background-color: var(--wu-brand-subtle);}.tile:hover { color: var(--wu-text-brand);}.tile:hover { box-shadow: var(--wu-shadow-md);}.tile:hover { --wu-translate-y: calc(-1 * var(--wu-space-1)); transform: translateX(var(--wu-translate-x)) translateY(var(--wu-translate-y)) translateZ(var(--wu-translate-z)) rotate(var(--wu-rotate)) skewX(var(--wu-skew-x)) skewY(var(--wu-skew-y)) scaleX(var(--wu-scale-x)) scaleY(var(--wu-scale-y));}
.tile--faded { opacity: var(--wu-opacity-75);}.tile--faded:hover { opacity: var(--wu-opacity-100);}Each item with a state compiles to its own rule. Color classes are listed on Colors.
Focus and active
Section titled “Focus and active”- Classes.
focus:andactive:exist for border colors, border widths and styles, opacity and shadows:focus:border-brand,focus:shadow-md,active:opacity-75. Background and text colors have nofocus:oractive:class. - apply(). Every mixin takes
focus,focus-visible,focus-withinandactive.focus-visibleandfocus-withinexist only inapply().
<div class="flex col gap-3 p-4 bg-alternate rounded"> <input type="text" placeholder="Focus me" class="focus:border-brand focus:shadow-md"> <button type="button" class="active:opacity-75">Press me</button></div>.field { @include apply(border-color(control), border-color(brand) focus, shadow(md) focus);}.button { @include apply(opacity(75) active);}.field { border-color: var(--wu-border-control);}.field:focus { border-color: var(--wu-brand);}.field:focus { box-shadow: var(--wu-shadow-md);}
.button:active { opacity: var(--wu-opacity-75);}focus versus focus-visible
Section titled “focus versus focus-visible”focusmatches whenever the element has focus, including after a mouse click on a button.focus-visiblematches only when the browser decides a focus indicator should show, typically keyboard focus and any focus on a text field. Use it for focus rings, so a mouse click on a button does not leave a ring behind.
.field-ring { @include apply(border-color(brand) focus-visible);}.field-ring:focus-visible { border-color: var(--wu-brand);}The base layer already gives buttons (button, and input of type submit, button and reset) and fields (text-like input types, textarea, select) a focus ring on :focus-visible, built from the focus tokens:
outline: var(--wu-border-focus-width) solid var(--wu-border-focus);outline-offset: var(--wu-border-focus-offset);The base layer also styles :hover, :active and :disabled on buttons and :hover and :disabled on fields. Base styles live in @layer base, so a state class or an apply() rule always wins over them. In the example above, the input keeps its base focus ring and adds the brand border and shadow from the focus: classes.
Group hover
Section titled “Group hover”Mark a parent with the group class, then use group-hover: classes on any descendant. The descendant changes while the parent is hovered. In apply(), group-hover compiles to .group:hover &.
Plans, invoices and payment methods.
<div class="group flex between x-center gap-4 p-4 bg-surface border rounded-lg transition-colors hover:border-brand"> <div> <strong class="font-semibold text-regular transition-colors group-hover:text-brand">Billing settings</strong> <p class="text-muted mt-1">Plans, invoices and payment methods.</p> </div> <span class="text-muted transition-transform group-hover:translate-x-1">Open</span></div>.card__title { @include apply(text-color(regular), text-color(brand) group-hover);}.card__arrow { @include apply(transition-transform, translate-x(1) group-hover);}.card__title { color: var(--wu-text-regular);}.group:hover .card__title { color: var(--wu-text-brand);}
.card__arrow { --wu-transition-transform: transform; transition-property: var(--wu-transition-colors, --wu-none), var(--wu-transition-opacity, --wu-none), var(--wu-transition-shadow, --wu-none), var(--wu-transition-transform, --wu-none); transition-duration: var(--wu-duration-200); transition-timing-function: var(--wu-ease-in-out);}.group:hover .card__arrow { --wu-translate-x: var(--wu-space-1); transform: translateX(var(--wu-translate-x)) translateY(var(--wu-translate-y)) translateZ(var(--wu-translate-z)) rotate(var(--wu-rotate)) skewX(var(--wu-skew-x)) skewY(var(--wu-skew-y)) scaleX(var(--wu-scale-x)) scaleY(var(--wu-scale-y));}The parent in the SCSS version still needs the group class in the HTML, because the compiled selector reads .group:hover. group-hover combines with a breakpoint like any other state:
.card__icon { @include apply(opacity(50), opacity(100) group-hover md);}.card__icon { opacity: var(--wu-opacity-50);}@media (min-width: 768px) { .group:hover .card__icon { opacity: var(--wu-opacity-100); }}Class support
Section titled “Class support”Which state prefixes the compiled classes have, per family. “Responsive” means the state classes also have @breakpoint forms, such as hover:opacity-100@md.
| Family | Examples | hover: |
group-hover: |
active: |
focus: |
Responsive |
|---|---|---|---|---|---|---|
| Background color | bg-brand, bg-brand-subtle, bg-red-300, bg-surface |
✓ | ✓ | No | ||
| Text color | text-brand, text-muted, text-red-700 |
✓ | ✓ | No | ||
| Border color | border-brand, border-control, border-red-300 |
✓ | ✓ | ✓ | ✓ | No |
| Border width | border-0, border-1, border-2, border-4, border-8 |
✓ | ✓ | ✓ | ✓ | No |
| Border style | border-solid, border-dashed, border-dotted, border-double, border-none |
✓ | ✓ | ✓ | ✓ | No |
| Width and height | w-4, w-50p, h-16, h-100p |
✓ | ✓ | Yes | ||
| Opacity | opacity-0 to opacity-100 |
✓ | ✓ | ✓ | ✓ | Yes |
| Shadow | shadow-md, shadow-lg-dark |
✓ | ✓ | ✓ | hover: and focus: |
|
| Translate | translate-x-4, -translate-y-1, translate-x-50p |
✓ | ✓ | Yes | ||
| Rotate | rotate-45, -rotate-90 |
✓ | ✓ | Yes | ||
| Scale | scale-105, scale-x-50, scale-y-150 |
✓ | ✓ | Yes | ||
| Origin | origin-top, origin-bottom-left |
✓ | ✓ | Yes |
Width and height state classes cover the spacing keys and the percentage keys, not the keywords (w-full, h-screen). text-on-{role} classes have no state forms. Spacing, display and layout classes have no state prefix; apply() gives any of them a state, as in p(6) hover or hide disabled.
States in apply()
Section titled “States in apply()”apply() accepts 22 states. Each compiles to &:state, except group-hover, which compiles to .group:hover &.
| Group | States |
|---|---|
| Interaction | hover, focus, focus-visible, focus-within, active, group-hover |
| Links and targets | visited, target |
| Form controls | checked, disabled, enabled, required, valid, invalid, placeholder-shown, read-only |
| Structure | first-child, last-child, only-child, first-of-type, last-of-type, empty |
Form states
Section titled “Form states”Form states have no classes, so write them in SCSS. Write the items in any order: the output follows the state order described under Output order.
.field { @include apply( border-color(control), border-color(danger) invalid, border-color(success) valid, bg(alternate) placeholder-shown, bg(disabled) disabled, border-color(strong) required, text-color(muted) read-only );}
.toggle { @include apply(bg(surface), bg(brand) checked);}.field { border-color: var(--wu-border-control);}.field:placeholder-shown { background-color: var(--wu-bg-alternate);}.field:required { border-color: var(--wu-border-strong);}.field:valid { border-color: var(--wu-success);}.field:invalid { border-color: var(--wu-danger);}.field:read-only { color: var(--wu-text-muted);}.field:disabled { background-color: var(--wu-bg-disabled);}
.toggle { background-color: var(--wu-bg-surface);}.toggle:checked { background-color: var(--wu-brand);}:invalid matches a required field before the user types anything. The base layer marks fields invalid with [aria-invalid="true"] and :user-invalid instead, which wait for user input; use the nesting pattern below for those.
@use "@wovenui/core" as *;
.field { @include apply( border-color(control), border-color(danger) invalid, border-color(success) valid, bg(alternate) placeholder-shown, bg(disabled) disabled, border-color(strong) required, text-color(muted) read-only );}
.toggle { @include apply(bg(surface), bg(brand) checked);}
.toggle { appearance: none; @include apply(width(5), height(5), border(1, solid, control), rounded(sm));}<div class="flex col gap-3"> <input type="text" class="field" placeholder="Required: type to make it valid" required> <label><input type="checkbox" class="toggle" checked> Checked</label> <label><input type="checkbox" class="toggle"> Not checked</label> <input type="text" class="field" placeholder="Disabled" disabled></div>.field { border-color: var(--wu-border-control);}.field:placeholder-shown { background-color: var(--wu-bg-alternate);}.field:required { border-color: var(--wu-border-strong);}.field:valid { border-color: var(--wu-success);}.field:invalid { border-color: var(--wu-danger);}.field:read-only { color: var(--wu-text-muted);}.field:disabled { background-color: var(--wu-bg-disabled);}
.toggle { background-color: var(--wu-bg-surface);}.toggle:checked { background-color: var(--wu-brand);}
.toggle { appearance: none; width: var(--wu-space-5); height: var(--wu-space-5); border-width: var(--wu-border-width-1); border-style: var(--wu-border-style); border-color: var(--wu-border-control); --wu-border-style: solid; border-radius: var(--wu-radius-sm);}The second .toggle rule only makes the native checkbox paint its background, so the checked color shows in the preview.
One state and one breakpoint per item
Section titled “One state and one breakpoint per item”Each item takes at most one state and one breakpoint, in either order. shadow(md) hover lg and shadow(md) lg hover give the same rule. Breakpoints are covered on Responsive.
.panel-a { @include apply(shadow(md) hover lg);}.panel-b { @include apply(shadow(md) lg hover);}@media (min-width: 1024px) { .panel-a:hover { box-shadow: var(--wu-shadow-md); }}
@media (min-width: 1024px) { .panel-b:hover { box-shadow: var(--wu-shadow-md); }}To style two states, write two items: shadow(md) hover, shadow(md) focus.
Output order
Section titled “Output order”apply() does not emit items in the order they are written. Items without a modifier come first, then items with a state, then each breakpoint from the smallest up with its own state items. Within each group, states follow this order:
first-child,last-child,only-child,first-of-type,last-of-type,emptytarget,visitedchecked,placeholder-shown,required,valid,invalid,read-onlygroup-hover,focus-within,hoverfocus,focus-visibleactiveenabled,disabled
Every state rule has the same specificity, one class and one pseudo-class, so the later rule wins when two match at once. The order follows the link, visited, hover, active rule of CSS: focus comes after hover, so a focused element that is also hovered shows its focus style; active comes after both, so a pressed element shows its pressed style; disabled comes last, so a disabled element never shows hover, focus or active styles.
.action { @include apply( opacity(50) disabled, bg(brand-active) active, bg(brand) focus, bg(brand-hover) hover, bg(surface) );}.action { background-color: var(--wu-bg-surface);}.action:hover { background-color: var(--wu-brand-hover);}.action:focus { background-color: var(--wu-brand);}.action:active { background-color: var(--wu-brand-active);}.action:disabled { opacity: var(--wu-opacity-50);}@use "@wovenui/core" as *;
.action { @include apply( opacity(50) disabled, bg(brand-active) active, bg(brand) focus, bg(brand-hover) hover, bg(surface), text-color(regular), text-color(on-brand) hover, text-color(on-brand) focus, text-color(on-brand) active );}<div class="flex gap-3"> <button type="button" class="action">Hover, then press</button> <button type="button" class="action" disabled>Disabled</button></div>.action { background-color: var(--wu-bg-surface); color: var(--wu-text-regular);}.action:hover { background-color: var(--wu-brand-hover);}.action:hover { color: var(--wu-text-on-brand);}.action:focus { background-color: var(--wu-brand);}.action:focus { color: var(--wu-text-on-brand);}.action:active { background-color: var(--wu-brand-active);}.action:active { color: var(--wu-text-on-brand);}.action:disabled { opacity: var(--wu-opacity-50);}Beyond the list
Section titled “Beyond the list”Any other pseudo-class, pseudo-element or attribute selector is plain SCSS nesting around apply(). Items with a state inside the nested rule add to the nested selector.
.menu-toggle { @include apply(bg(surface), bg(hover) hover);
&[aria-expanded="true"] { @include apply(bg(selected), text-color(brand)); }
&:not(:disabled) { @include apply(shadow(sm) hover); }}
.search::placeholder { @include apply(text-color(subtle), italic);}
.list > li:nth-child(odd) { @include apply(bg(alternate));}.menu-toggle { background-color: var(--wu-bg-surface);}.menu-toggle:hover { background-color: var(--wu-bg-hover);}.menu-toggle[aria-expanded=true] { background-color: var(--wu-bg-selected); color: var(--wu-text-brand);}.menu-toggle:not(:disabled):hover { box-shadow: var(--wu-shadow-sm);}
.search::placeholder { color: var(--wu-text-subtle); font-style: italic;}
.list > li:nth-child(odd) { background-color: var(--wu-bg-alternate);}@use "@wovenui/core" as *;
.menu-toggle { @include apply(bg(surface), bg(hover) hover);
&[aria-expanded="true"] { @include apply(bg(selected), text-color(brand)); }
&:not(:disabled) { @include apply(shadow(sm) hover); }}<div class="flex gap-3"> <button type="button" class="menu-toggle text-regular" aria-expanded="true">Menu (expanded)</button> <button type="button" class="menu-toggle text-regular" aria-expanded="false">Menu (collapsed)</button></div>.menu-toggle { background-color: var(--wu-bg-surface);}.menu-toggle:hover { background-color: var(--wu-bg-hover);}.menu-toggle[aria-expanded=true] { background-color: var(--wu-bg-selected); color: var(--wu-text-brand);}.menu-toggle:not(:disabled):hover { box-shadow: var(--wu-shadow-sm);}Errors
Section titled “Errors”A wrong modifier stops the compile with a message that names the item. More on compile errors: Errors.
| Input | Message |
|---|---|
apply(bg(brand) hovr) |
apply(bg(brand) hovr): unknown modifier hovr. Use a breakpoint (xs, sm, md, lg, xl, 2xl) or a state (first-child, last-child, only-child, first-of-type, last-of-type, empty, target, visited, checked, placeholder-shown, required, valid, invalid, read-only, group-hover, focus-within, hover, focus, focus-visible, active, enabled, disabled). |
apply(bg(brand) hover focus) |
apply(bg(brand) hover focus): more than one state (hover, focus). Use one state per item. |
apply(p(4) md lg) |
apply(p(4) md lg): more than one breakpoint (md, lg). Use one breakpoint per item. |
apply(hover:bg(brand)) |
expected ")". (a Sass parse error at the colon) |
apply(hover bg(brand)) |
(_n: "bg", _p: ((brand,))) isn't a valid CSS value. |
The last two are a modifier written before the utility. Sass rejects them before apply() can name the problem, so the message is less direct: move the state after the item, as in bg(brand) hover.