Skip to content

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.

  • 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-hover is 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.

Hover each tile
hover:bg-brand-subtle
hover:text-brand
hover:shadow-md
hover:opacity-100
hover:-translate-y-1
<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 {
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.

  • Classes. focus: and active: 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 no focus: or active: class.
  • apply(). Every mixin takes focus, focus-visible, focus-within and active. focus-visible and focus-within exist only in apply().
Click or tab into the field, then press the button
<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 {
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 matches whenever the element has focus, including after a mouse click on a button.
  • focus-visible matches 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.

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 &.

Hover anywhere on the card
Billing settings

Plans, invoices and payment methods.

Open
<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 {
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);
}
}

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.

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 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.

The form-state rules above: the empty required field is invalid until you type, the checkbox fills when checked, the last field is disabled
@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));
}

The second .toggle rule only makes the native checkbox paint its background, so the checked color shows in the preview.

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.

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:

  1. first-child, last-child, only-child, first-of-type, last-of-type, empty
  2. target, visited
  3. checked, placeholder-shown, required, valid, invalid, read-only
  4. group-hover, focus-within, hover
  5. focus, focus-visible
  6. active
  7. enabled, 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);
}
The .action rule above, with a text color per fill: hover shows brand-hover, pressing shows brand-active, focus shows brand; the second button is disabled
@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
);
}

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);
}
The .menu-toggle rule above: aria-expanded="true" on the left, "false" on the right
@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);
}
}

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.