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.
Right to left
Section titled “Right to left”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,mxandspace-xare logical. They writepadding-inline,margin-inlineandmargin-inline-start.inset-x-*andborder-xare the physical exceptions: they writeleftandright.- 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.
<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>.nav-item { @include apply(ps(4), border-start(4), text-start);}.nav-item__icon { @include apply(ms-auto);}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;}@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);}<a href="#right-to-left" class="nav-item"> <span>Inbox</span> <svg class="nav-item__icon" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><circle cx="6" cy="6" r="4" fill="currentColor" /></svg></a>.nav-item { display: flex; align-items: center; padding-inline-start: var(--wu-space-4); padding-inline-end: var(--wu-space-2); padding-block: var(--wu-space-2); border-inline-start-width: var(--wu-border-width-4); border-inline-start-style: var(--wu-border-style); border-inline-start-color: var(--wu-brand); --wu-border-style: solid; text-align: start; background-color: var(--wu-bg-surface);}
.nav-item__icon { margin-inline-start: 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);}@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); }}<button type="button" class="chip">Design</button><button type="button" class="chip">Engineering</button>.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); color: var(--wu-text-brand);}.chip:focus-visible { outline: var(--wu-border-focus-width) solid var(--wu-border-focus); outline-offset: var(--wu-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 contrast
Section titled “Text contrast”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.
Your own colors
Section titled “Your own colors”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 themetext-regularandtext-invertedhas 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)outputsvar(--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()
Section titled “adaptive-contrast()”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().
Reduced motion
Section titled “Reduced motion”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-inand the rest),.hover-ready, the transition classes, the button and field transitions of the base layer and the tooltip fade carry noprefers-reduced-motionquery. 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,
!importantincluded.
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; } }}High contrast
Section titled “High contrast”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; }}Screen readers
Section titled “Screen readers”.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><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-visibleas 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-tooltipalone. Do not addrole="tooltip"to the trigger: it would replace the trigger’s real role (button, link) for assistive technology.
See Tooltips for positions and variables.
Base layer defaults
Section titled “Base layer defaults”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 |