Skip to content

Accessibility and RTL

Core handles a set of accessibility and direction concerns for you: logical utilities that mirror under dir="rtl", a focus ring on buttons and fields, computed text colors on role fills, a reduced-motion rule in the reset, high-contrast theme maps and a .sr-only class. This page explains each one and shows how to reuse it in your own components.

Logical utilities and mixins follow the writing direction. Under dir="rtl" the inline start is the right side, so ps-4 pads the right edge and border-s-4 draws the right border. Physical utilities keep their side in both directions.

Logical (mirrors under dir="rtl") Physical (keeps its side)
ps-*, pe-*, px-* pl-*, pr-*
ms-*, me-*, mx-*, ms-auto, me-auto, mx-auto ml-*, mr-*
space-x-*
start-*, end-* (mixins inset-start(), inset-end()) left-*, right-*, inset-x-*
border-s, border-e, border-s-{w}, border-e-{w} border-l, border-r, border-x
rounded-s-*, rounded-e-*, rounded-ss-*, rounded-se-*, rounded-es-*, rounded-ee-* rounded-l-*, rounded-r-*, rounded-tl-* and the other corners
text-start, text-end text-left, text-right
  • Use logical by default. Spacing, borders, corners and alignment that follow the reading order (a sidebar accent, an icon after a label, indented text) are logical. The page then works in both directions with no extra CSS.
  • Use physical for screen geometry. Content that must stay on one side of the screen whatever the language (a close button pinned to the top right corner of a media viewer, a horizontal chart axis) uses the physical classes.
  • px, mx and space-x are logical. They write padding-inline, margin-inline and margin-inline-start. inset-x-* and border-x are the physical exceptions: they write left and right.
  • Tooltip positions are physical. data-tooltip-position="left" is always the left side of the screen. See Tooltips.

The base layer is logical too: blockquotes take border-inline-start, and checkboxes and radios take margin-inline-end.

The same sidebar item in LTR and RTL
<a href="#" class="flex x-center gap-3 ps-4 pe-2 py-2 border-s-4 border-brand text-start bg-surface">
<span>Inbox</span>
<svg class="ms-auto" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><circle cx="6" cy="6" r="4" fill="currentColor" /></svg>
</a>

The accent border, the start padding and the icon all move to the other side in the RTL preview. The mixins compile to logical properties:

.nav-item {
padding-inline-start: var(--wu-space-4);
border-inline-start-width: var(--wu-border-width-4);
border-inline-start-style: var(--wu-border-style);
border-inline-start-color: var(--wu-border-regular);
--wu-border-style: solid;
text-align: start;
}
.nav-item__icon {
margin-inline-start: auto;
}
The logical mixins, compiled and rendered in LTR and RTL
@use "@wovenui/core" as *;
.nav-item {
@include apply(flex, x-center, ps(4), pe(2), py(2), border-start(4, solid, brand), text-start, bg(surface));
}
.nav-item__icon {
@include apply(ms-auto);
}

The spacing scale and every logical class are listed in Spacing.

The base layer gives buttons and form fields a visible keyboard focus ring. It uses :focus-visible, so it appears for keyboard focus and not for a mouse click. The ring is built from three tokens:

Token Light Dark
--wu-border-focus (ring color) brand 600 brand 400
--wu-border-focus-width 2px 2px
--wu-border-focus-offset 2px 2px

Compiled base rule for buttons (fields get the same two declarations):

@layer base {
button:focus-visible,
input[type=submit]:focus-visible,
input[type=button]:focus-visible,
input[type=reset]:focus-visible {
outline: var(--wu-border-focus-width) solid var(--wu-border-focus);
outline-offset: var(--wu-border-focus-offset);
}
}

Give your own interactive component the same ring with token(). It follows the theme and changes everywhere when you override the focus tokens:

@use "@wovenui/core" as *;
.chip {
@include apply(px(3), py(1), rounded(full), bg(brand-subtle));
&:focus-visible {
outline: token(border-focus-width) solid token(border-focus);
outline-offset: token(border-focus-offset);
}
}
.chip {
padding-inline: var(--wu-space-3);
padding-block: var(--wu-space-1);
border-radius: var(--wu-radius-full);
background-color: var(--wu-brand-subtle);
}
.chip:focus-visible {
outline: var(--wu-border-focus-width) solid var(--wu-border-focus);
outline-offset: var(--wu-border-focus-offset);
}
Two chips as buttons: press Tab to move the focus ring between them
@use "@wovenui/core" as *;
.chip {
@include apply(px(3), py(1), rounded(full), bg(brand-subtle), text-color(brand));
&:focus-visible {
outline: token(border-focus-width) solid token(border-focus);
outline-offset: token(border-focus-offset);
}
}

The chips are buttons, so text-color(brand) replaces the text-on-brand color that the base layer gives buttons.

focus-visible is also an apply() state, so any utility can be scoped to keyboard focus. focus-within covers a container whose child has focus. See States for the full list and their order.

.tab {
@include apply(border-bottom(2, solid, transparent), border-color(brand) focus-visible, bg(hover) hover);
}
.tab {
border-bottom-width: var(--wu-border-width-2);
border-bottom-style: var(--wu-border-style);
border-bottom-color: var(--wu-transparent);
--wu-border-style: solid;
}
.tab:hover {
background-color: var(--wu-bg-hover);
}
.tab:focus-visible {
border-color: var(--wu-brand);
}

text-on-{role} (the text placed on a solid role fill, such as --wu-text-on-brand) is not a fixed color. Core computes it per theme: it takes the theme’s own text-regular and text-inverted and picks the one with the higher WCAG contrast against that theme’s {role} fill. With the defaults, light text-on-brand is #f1f1f1 and light text-on-warning is #1a1a1a.

When the better of the two stays below 4.5:1, the compile emits a warning naming the map to fix. Pointing the light brand fill at stop 500 of the default brand gives:

@use "@wovenui/core" as * with (
$light-theme: ("brand": brand 500)
);
WARNING: text-on-brand ($light-theme): the better of text-regular and text-inverted reaches 4.37:1 on the brand fill (#007bff), below 4.5:1. Point the fill at another stop or set text-on-brand in $light-theme.

The build still succeeds. Fix it by pointing the fill at a darker stop, or by setting text-on-brand in $light-theme to a color you have checked. Override maps are covered in Theming.

One documented exception ships with the defaults: light text-warning (warning stop 800) is 5.53:1 on white but 4.42:1 on gray 100, the color of bg-alternate and bg-hover. Avoid warning text on those surfaces when the text is small.

For colors outside the roles, two tools compute the text color at compile time:

  • find-text-color($background) is a function. It returns whichever of the light theme text-regular and text-inverted has the higher contrast on an opaque color.
  • text-on-color($color, [$stop]) is a mixin. A role gives the themed token (text-on-color(brand) outputs var(--wu-text-on-brand)); anything else is computed at compile time.
@use "@wovenui/core" as *;
.promo {
background-color: #0f766e;
color: find-text-color(#0f766e);
}
.badge {
@include apply(text-on-color(#facc15));
background-color: #facc15;
}
.tag {
@include apply(text-on-color(red, 700), bg(red, 700));
}
.promo {
background-color: #0f766e;
color: #f1f1f1;
}
.badge {
color: #1a1a1a;
background-color: #facc15;
}
.tag {
color: #f1f1f1;
background-color: var(--wu-red-700);
}

These values are fixed: they do not follow a theme switch. Use them for colors that are the same in every theme.

adaptive-contrast($color) sets the background and the computed text color, then raises the contrast for users who ask for it with prefers-contrast: more: the background moves 10% lightness away from the text color (darker under light text, lighter under dark text).

@use "@wovenui/core" as *;
.banner {
@include adaptive-contrast(#1e293b);
}
.banner {
background-color: #1e293b;
color: #f1f1f1;
}
@media (prefers-contrast: more) {
.banner {
background-color: rgb(5.0231328486%, 6.8649482265%, 9.8788279357%);
color: #f1f1f1;
}
}

Like find-text-color(), the output is resolved colors, not tokens. Use it standalone rather than inside apply().

The reset layer includes a global rule for users who set prefers-reduced-motion: reduce. It is part of every build that emits CSS: the prebuilt wovenui.css and any stylesheet that loads @wovenui/core (not the CSS-free @wovenui/core/mixins entry).

@layer reset {
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}

What this means in practice:

  • Built-in classes have no rule of their own. The animation classes (.animate-spin, .animate-pulse, .animate-fade-in and the rest), .hover-ready, the transition classes, the button and field transitions of the base layer and the tooltip fade carry no prefers-reduced-motion query. That rule is the only one in the class build, and it covers all of them.
  • Animations run once, instantly. Every animation is cut to one 0.01ms iteration, so a spinner stops and a fade-in ends at its final state.
  • Transitions jump. State changes still happen (a hover color, a lift) but with no visible movement in between.
  • Your own CSS is covered. The selector is *, so hand-written animations and transitions are shortened too.
  • It cannot be overridden by accident. Important declarations in an earlier cascade layer beat later layers and unlayered CSS, !important included.

The rule shortens motion but does not remove a change of position. Use the reduced-motion mixin (@media (prefers-reduced-motion: reduce)) when the movement itself should go, for example a card that lifts on hover:

@use "@wovenui/core" as *;
.card {
@include apply(transition-transform, translate-y(-1) hover);
@include reduced-motion {
transition: none;
&:hover {
transform: none;
}
}
}
.card {
--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);
}
.card: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));
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover {
transform: none;
}
}

An essential animation, such as a progress spinner, is restored inside the same reset layer, where its !important wins over the * rule by specificity:

@layer reset {
.spinner {
@include reduced-motion {
animation-duration: 1s !important;
animation-iteration-count: infinite !important;
}
}
}

Core supports two different things under the name high contrast.

data-contrast="high" high-contrast mixin
Triggered by An attribute you set, usually from a user setting The OS forced-colors mode (Windows contrast themes)
Media feature None @media (forced-colors: active)
Colors Your token overrides from $high-contrast-light and $high-contrast-dark A small system palette chosen by the OS (ButtonText, Canvas and others)
Configured in with() Your component styles

data-contrast="high" applies token override maps. Both maps are empty by default, and nothing is emitted until you set one. The light map applies wherever the theme resolves to light and the dark map wherever it resolves to dark:

@use "@wovenui/core" as * with (
$high-contrast-light: ("text-muted": #000, "border-control": gray 900),
$high-contrast-dark: ("text-muted": #fff)
);
<html data-contrast="high">

Selector rules, recomputed text-on-* values and nesting are covered in Theming.

The high-contrast mixin wraps content in the forced-colors media query. In that mode the browser replaces your colors, so backgrounds that carry meaning disappear. Add a border or outline in a system color to keep the shape visible:

@use "@wovenui/core" as *;
.button {
@include apply(bg(brand), text-color(on-brand), border(0));
@include high-contrast {
border: 1px solid ButtonText;
}
}
.button {
background-color: var(--wu-brand);
color: var(--wu-text-on-brand);
border-width: var(--wu-border-width-0);
}
@media (forced-colors: active) {
.button {
border: 1px solid ButtonText;
}
}

.sr-only hides content visually while keeping it in the accessibility tree. Use it for a label on an icon-only button or extra context in a link. It is part of the base layer, so it is available with the base flag on, whether or not utility classes are generated:

@layer base {
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip-path: inset(50%) !important;
white-space: nowrap !important;
border: 0 !important;
}
}
<button type="button">
<svg aria-hidden="true" width="16" height="16"><!-- icon --></svg>
<span class="sr-only">Close dialog</span>
</button>
An icon button with a .sr-only label: the label is in the markup but not on screen
<button type="button" class="flex inline x-center">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="2" /></svg>
<span class="sr-only">Close dialog</span>
</button>

Tooltips are built for keyboard users too:

  • Shown on focus. The bubble appears on :focus-visible as well as :hover, so a keyboard user sees the same label as a mouse user.
  • No role on the trigger. A tooltip is set with data-tooltip alone. Do not add role="tooltip" to the trigger: it would replace the trigger’s real role (button, link) for assistive technology.

See Tooltips for positions and variables.

The base layer styles plain elements from tokens. It lives in @layer base, so utility classes and your own unlayered CSS always win over it without !important. It follows the base feature flag, independent of $generate-utility-classes; see Configuration.

Element What it gets
body bg-base background, text-regular color, the --wu-font-family font, normal weight and line height
Buttons (button, input of type submit, button, reset) brand fill with text-on-brand text, control-height-md minimum height, brand-hover and brand-active on hover and press, the focus ring, 0.5 opacity and a not-allowed cursor when disabled
Fields (input without a type, text-like types, textarea, select) Full width, bg-surface background, text-regular text, a 1px border-control border that turns border-control-hover on hover, the focus ring, text-subtle placeholders, bg-disabled and 0.5 opacity when disabled
Single-line fields and select Height control-height-md. A select with multiple or a size other than 1 keeps its natural list box height
Invalid fields (aria-invalid="true" or :user-invalid) --wu-danger border (--wu-border-control when no danger role exists) and no hover border change
Checkboxes and radios margin-inline-end of space 2 before the label
Links without a class text-link, text-link-hover on hover