Version 0.4.0 now released! See the release notes on GitHub Releases

→ Salty can't make your site accessible — it can make the accessible version cheaper to write.

Accessibility

Salty can't make your site accessible. What it can do is make the accessible version the shorter one to write — and that matters more than it sounds, because most accessibility bugs in a component tree aren't exotic. They're a <div onClick> a keyboard can't reach, an action that only appears on hover, a focus ring someone deleted because it looked wrong after a mouse click, a list that stopped being a list the moment the design turned it into a grid.

Nobody sets out to do any of that. It happens because in the moment, the correct version was the one that needed more work — an extra wrapper, an extra prop, an extra thing to remember at forty call sites.

Salty doesn't solve the hard part. A combobox, a modal, a drag-and-drop reorder — those are real engineering problems, and no styling library hands them to you. What it removes is a set of small excuses: you can swap the rendered element per instance, style directly off the attributes assistive tech already reads, and bind attributes to a component so nobody has to remember them. Nine patterns below, each one a define-then-use pair.

Where a platform detail deserves more than my summary of it, I link to MDN instead of restating it here — the web has never been short of good accessibility guides, settled patterns, or purpose-built APIs. It's short of people using them, and no amount of documentation has fixed that yet.

1. Render the element the job actually calls for

Heading level is document structure. Screen reader users navigate by it — jumping heading to heading the way you skim a page with your eyes — so an outline with a hole in it is a page with a broken table of contents. Font size is a visual decision. Tie the two together and every design tweak becomes a structural change.

styled keeps them as two separate knobs.

How you define it

The tag is the first argument; the looks are variants:

components/heading.css.ts
import { styled } from "@salty-css/react/styled";

export const Heading = styled("h2", {
  base: { margin: 0, fontWeight: 700, lineHeight: 1.1 },
  variants: {
    size: {
      sm: { fontSize: "1.25rem" },
      md: { fontSize: "1.75rem" },
      lg: { fontSize: "2.5rem" },
      xl: { fontSize: "3.5rem" },
    },
  },
  defaultVariants: { size: "md" },
});

How you use it

as overrides the rendered tag per instance. The variant props stay typed regardless of what it resolves to:

Example
// The page's one <h1>, rendered large.
<Heading as="h1" size="xl">Salty CSS</Heading>

// A section heading — <h2> is the default, so no `as` needed.
<Heading size="lg">What you'll learn</Heading>

// Looks like a headline, isn't one: a card label under an existing <h2>.
<Heading as="p" size="sm">Recipe</Heading>

That last one is the case people miss. Not every big bold string is a heading, and a card grid that emits fifteen <h3>s gives a screen reader user a table of contents made of noise.

At definition time the same override is the element option — styled("div", { element: "section" }) styles one thing and renders another. When the first argument is a component rather than a tag, that component owns the tag; wrap the one that already renders what you want, forwarding whatever it needs with passProps:

components/button.css.ts
import { styled } from "@salty-css/react/styled";
import NextLink from "next/link";

const buttonLook = {
  display: "inline-flex",
  alignItems: "center",
  gap: "0.5em",
  padding: "0.6em 1.2em",
  borderRadius: "6px",
};

export const Button = styled("button", { base: buttonLook });

// Same look, but it's a real link — href and all.
export const ButtonLink = styled(NextLink, {
  passProps: ["href"],
  base: buttonLook,
});

A link and a button are not the same control even when they're identical to look at. A link navigates: it gets ⌘-click, right-click open in new tab, a status-bar preview, and it's announced as a link. A button acts: it fires on Space as well as Enter, participates in forms, and can be genuinely disabled. Choosing between them by appearance is how you end up at <div role="button" tabIndex={0}> with a hand-rolled key handler that only listens for Enter — which is the spaghetti this whole page is trying to talk you out of. The tag is the cheapest accessibility decision you'll make all week.

2. Semantic wrappers that don't cost you the layout

The usual reason a <ul> disappears from a card grid: the grid is on the parent, the <li>s are children of the list, and adding the list breaks the layout. So the list gets deleted and the cards become bare <div>s. Now nothing announces "list, 6 items," and nothing lets a screen reader user jump past it.

display: contents removes an element's own box while leaving its children exactly where they were in the flow. The layout stops seeing the wrapper; the accessibility tree keeps it.

How you define it

components/card-grid.css.ts
import { styled } from "@salty-css/react/styled";

export const CardGrid = styled("section", {
  base: {
    display: "grid",
    gridTemplateColumns: "repeat(auto-fit, minmax(16rem, 1fr))",
    gap: "{spacing.medium}",
  },
});

// Semantics with no box of its own.
export const CardList = styled("ul", {
  defaultProps: { role: "list" },
  base: {
    display: "contents",
    listStyle: "none",
    margin: 0,
    padding: 0,
  },
});

How you use it

Example
<CardGrid>
  <CardList>
    <li><Card>…</Card></li>
    <li><Card>…</Card></li>
  </CardList>
</CardGrid>

The <li>s are the grid items. The grid doesn't know the list is there; assistive tech does. The same move works for a <nav> inside a flex bar, or a <fieldset> around a set of radios, whose default box is famously awkward to lay out.

Three caveats, because this one has sharp edges:

  • The box is genuinely gone. Background, border, padding, overflow, transforms — all of it goes with the box. If the wrapper needs to look like anything, display: contents isn't available to it.
  • Don't put it on something focusable, or on anything whose role depends on having a box.
  • It has an accessibility history. For a while browsers dropped display: contents elements out of the accessibility tree entirely, which defeated the entire purpose. The common cases are fixed, but list semantics are still fragile — Safari drops them when list-style: none is set, which most resets and half the components you've ever written do. The role="list" above puts them back. It costs nothing, and it's exactly the kind of thing you want bound to the component instead of remembered. MDN's display page has the accessibility notes in full.

3. Bind the attribute to the component, not to your memory

defaultProps sets HTML attributes that get passed straight through to the rendered element. It's a small option with an outsized effect here: an attribute in the atom's definition is an attribute nobody can forget.

How you define it

components/primitives.css.ts
import { styled } from "@salty-css/react/styled";

export const Button = styled("button", {
  // HTML's default is type="submit" — inside a form that's a bug waiting.
  defaultProps: { type: "button" },
  base: { padding: "0.6em 1.2em", borderRadius: "6px", cursor: "pointer" },
});

export const Icon = styled("svg", {
  // Decorative by default: don't read the icon out loud next to its own label.
  defaultProps: { "aria-hidden": true, focusable: "false" },
  base: { width: "1em", height: "1em", flexShrink: 0 },
});

export const List = styled("ul", {
  defaultProps: { role: "list" },
  base: { listStyle: "none", margin: 0, padding: 0 },
});

How you use it

Nothing at the call site. That's the whole point:

Example
<Button onClick={save}>
  <Icon><use href="#save" /></Icon> Save
</Button>

The rendered <button> carries type="button", so dropping it into a form doesn't submit the form by accident. The <svg> carries aria-hidden, so "Save" is announced once rather than twice. These are defaults — a call site that needs something else still passes its own value.

Two things worth keeping straight. defaultProps and defaultVariants look like siblings and aren't: defaultProps are DOM attributes that reach the element, defaultVariants pick a style branch and are consumed by Salty on the way. And an icon-only button still needs an accessible name — aria-hidden on the icon removes the only text there was, so add an aria-label on the button or a visually hidden span (there's an .sr-only helper in Global Styles). Otherwise the whole control is announced as just "button" — imagine being told to press "a button" and left to work out which one.

4. Style off the ARIA attribute so it can't drift

Here's the trick that makes the previous three stick: make the attribute assistive tech reads the same attribute your CSS keys off. Then the visual state and the announced state physically cannot disagree.

How you define it

components/disclosure.css.ts
import { styled } from "@salty-css/react/styled";

export const DisclosureButton = styled("button", {
  defaultProps: { type: "button" },
  base: {
    display: "flex",
    alignItems: "center",
    gap: "0.5em",
    "& svg": { transition: "transform 150ms ease" },
    // the state hook *is* the announced state
    "&[aria-expanded='true'] svg": { transform: "rotate(90deg)" },
  },
});

How you use it

Example
<DisclosureButton
  aria-expanded={open}
  aria-controls="shipping-details"
  onClick={() => setOpen(!open)}
>
  <Chevron /> Shipping details
</DisclosureButton>

<Panel id="shipping-details" hidden={!open}>…</Panel>

Forget aria-expanded and the chevron never turns. You catch it in the browser, in the first thirty seconds, instead of in an audit six months later — the bug became visible to the person most likely to notice it.

Compare the alternative: an expanded variant. It works, it's typed, and it quietly ships the wrong thing — variant props are consumed by Salty for styling and don't reach the DOM, so the chevron rotates and nobody is told anything. Interactive State has the full version of that trap under disabled, including passProps for when you want the styling and the attribute.

The same pattern covers aria-current="page" on nav links, aria-pressed on a toggle, aria-invalid on a field that failed validation, aria-sort on a sortable column header. Each is an attribute you should be setting anyway; the styling hook is free.

And the counterweight, because this pattern can encourage the opposite mistake: don't add ARIA where HTML already carries the state. <details> tracks open by itself, a checkbox has :checked, a form control has :disabled, and every one of those is a selector you can style today with no attribute to maintain. No ARIA is better than bad ARIA — MDN's ARIA reference is blunt about it, and Interactive State is the tour of what the browser will track for you before you reach for a single prop.

5. Hover and focus in the same rule

Reveal-on-hover is the single most common way a keyboard user gets locked out of a feature. The actions are in the DOM, they're in the tab order, they're focusable — and they're invisible, because the only rule that shows them is :hover.

:focus-within matches while focus is on an element or anything inside it, so it's the keyboard's version of hovering the row.

Revealing actions to both audiences

components/card.css.ts
import { styled } from "@salty-css/react/styled";
import { CardActions } from "./card-actions.css";

export const Card = styled("article", {
  base: {
    padding: "1rem",
    borderRadius: "8px",
    [`& ${CardActions}`]: { opacity: 0, transition: "opacity 150ms ease" },
    // pointer or keyboard — one rule, both audiences
    [`&:where(:hover, :focus-within) ${CardActions}`]: { opacity: 1 },
  },
});

(That ${CardActions} is targeting the other component by its identity rather than by tag — see Scoping & composition.)

Two reasons the grouping goes through :where(): it's a forgiving selector list, so one selector the browser doesn't know doesn't invalidate the whole rule the way a plain comma list would, and it contributes zero specificity, so the rule stays trivially overridable later. :is() groups the same way but keeps the specificity.

One precision note on the hiding method: opacity: 0 keeps those buttons in the tab order, and that's what makes this work. display: none or visibility: hidden would take them out, focus could never land inside, and :focus-within would never fire. The flip side is that they're still announced while invisible — if that's not what you want, this isn't the pattern.

And the focus ring itself

Example
export const IconButton = styled("button", {
  defaultProps: { type: "button" },
  base: {
    "&:focus-visible": {
      outline: "2px solid {colors.brand.main}",
      outlineOffset: "2px",
    },
  },
});

:focus-visible is the browser's own judgment about when a ring is useful — keyboard yes, mouse click on a button no. It settles the argument that produced a decade of outline: none: you don't have to choose between a ring on every click and no ring at all. If you do remove the default outline, replace it in the same rule. An invisible focus position is the fastest way to make a page unusable by keyboard, and it's invisible to you too, because you were using a mouse when you shipped it.

6. Turn motion off when it's been asked to be off

How you define it

Name the query once, alongside your breakpoints:

/styles/media.css.ts
import { defineMediaQuery } from "@salty-css/react/config";

export const reducedMotion = defineMediaQuery((media) => media.reducedMotion);

How you use it

Per component, where you know what the motion is doing:

Example
export const Panel = styled("section", {
  base: {
    animation: slideIn,
    "@reducedMotion": { animation: "none" },
  },
});

And once, globally, as the backstop for everything you didn't audit:

/styles/global.css.ts
import { defineGlobalStyles } from "@salty-css/core/factories";

export const globalStyles = defineGlobalStyles({
  "@reducedMotion": {
    "*, *::before, *::after": {
      animationDuration: "0.01ms !important",
      animationIterationCount: "1 !important",
      transitionDuration: "0.01ms !important",
      scrollBehavior: "auto !important",
    },
  },
});

Two things in there are deliberate. 0.01ms rather than none, so animations still start and finish — anything waiting on an animationend event keeps working instead of hanging forever on a callback that never fires. And the !important, which is normally the thing I'd talk you out of: Salty resolves conflicts with cascade layers, and !important inverts layer order. Here that inversion is the mechanism — globals sit in an earlier layer than components, so an !important in the global layer outranks an !important in a component. The kill switch actually kills. (The full explanation of that inversion, and why it usually bites rather than helps, is on Scoping & composition.)

Worth being honest about the preference itself: prefers-reduced-motion doesn't mean "no motion." It's about vestibular triggers — parallax, spinning, zooming, things flying across the viewport. A 150ms opacity fade is usually fine, and often clearer than an instant swap, because it tells you something changed. So treat the global rule as a safety net, and for the animations you've actually looked at, prefer swapping a slide for a cross-fade over deleting the feedback entirely. Animations covers the per-keyframe version.

7. Nudging a color until it's legible

The everyday version of this bug is one grey doing four jobs: borders, placeholder text, timestamps, disabled labels. It's the right value for exactly one of them. color() lets you derive the legible variant from the same source instead of hand-picking a second hex and hoping the two stay related.

How you define it

/styles/variables.css.ts
import { defineVariables } from "@salty-css/core/factories";
import { color } from "@salty-css/core/helpers";

const grey = "#8a8a8a"; // fine as a border on white; too light to read as text

export default defineVariables({
  colors: {
    border: { subtle: grey },
    text: {
      muted: color(grey).darken(0.25), // same hue, enough contrast to actually read
    },
  },
});

How you use it

Example
export const Timestamp = styled("time", {
  base: { color: "{colors.text.muted}", fontSize: "0.875rem" },
});

One source of truth for the hue, one derived value for the role that has to clear a threshold — and every component reads a token name that says what it's for.

Be clear about what this does and doesn't do: color() does not compute contrast ratios. .darken(0.25) moves the value in the right direction; it doesn't certify anything. Check the pair with your browser's contrast readout or any checker. The good news is that it resolves at build time, so the value you checked is the value that ships — it can't drift out from under you between builds.

The thresholds, since they're short: 4.5:1 for body text, 3:1 for large text (roughly 24px, or 19px bold) and for the non-text parts that carry meaning — icons, input borders, focus rings. Disabled controls are exempt, which is not the same as permission to make them unreadable.

One boundary carried over from the Color function page: color() can only transform values it can see at build time, so derive from a static atom and store the result as a themed molecule per mode, rather than trying to darken {theme.text} inside a component.

8. High contrast is two different things

They get talked about as one feature and behave nothing alike:

  • prefers-contrast: more — the user asked for more contrast. Your palette, your call on what "more" means.
  • forced-colors: active — Windows Contrast Themes. The OS replaces your colors with its own. You're not choosing any more; your job is making sure nothing you built depended on a color you no longer control.

Responding to prefers-contrast

Nobody sets an attribute for this — the environment decides — so it's the responsive scope, not conditional. Exactly the same split as OS dark mode on the Theming page.

/styles/media.css.ts
export const moreContrast = defineMediaQuery((media) =>
  media.custom("media (prefers-contrast: more)"),
);
/styles/themes.css.ts
import { defineVariables } from "@salty-css/core/factories";

export const themes = defineVariables({
  responsive: {
    base: {
      theme: {
        bg: "#ffffff",
        text: "#1a1a1a",
        mutedText: "#5a5a5a",
        border: "#d9d9d9",
      },
    },
    // only redeclare what changes
    "@moreContrast": {
      theme: { mutedText: "#1a1a1a", border: "#1a1a1a" },
    },
  },
});

Not a single component changes — they already read {theme.mutedText} and {theme.border}. That's the atoms-and-molecules payoff: high contrast becomes one more set of molecules, not a sweep through your component tree.

Surviving forced-colors

In forced-colors mode most background colors and images are replaced, so anything distinguishable only by its fill flattens into a rectangle of system background. The cheap insurance is a border that's already there:

Example
export const Button = styled("button", {
  base: {
    background: "{theme.accent}",
    // invisible normally, but present — so there's an edge to paint
    border: "1px solid transparent",
    "@media (forced-colors: active)": {
      borderColor: "ButtonBorder",
    },
  },
});

ButtonBorder is a system color keyword — you hand the browser a role and it fills in the user's own palette. The same reasoning applies anywhere state is carried by color alone: an active tab that's only a different background disappears, so give it an underline, an icon, or a border and it survives the switch.

There's also forced-color-adjust: none, which opts an element out of the user's palette. Be very reluctant. It's legitimate when the color is the content — a swatch in a color picker, a chart legend — and it's a bug everywhere else, because opting out is opting out of the exact thing the user asked for. MDN's forced-colors page covers what gets replaced.

9. Zoom, and why the floor is the guarantee

Two halves to this one, and the second is the non-obvious part.

Size text in rem so it follows the user's own setting

A helper is enough — this is the same rem from the Helpers page:

/styles/units.ts — a plain module, no Salty import needed
export const rem = (px: number, base = 16) => `${px / base}rem`;
/components/prose.css.ts
import { styled } from "@salty-css/react/styled";
import { rem } from "../styles/units";

export const Prose = styled("p", {
  base: { fontSize: rem(16), lineHeight: 1.5, maxWidth: rem(640) },
});

Someone who set their browser's default text size to 20px gets a 25% larger reading experience without touching zoom at all. A px value ignores them completely — and they're not a rare user; they're the user who found the setting because they needed it. (If you'd rather not import anything, defineConfig has a project-wide defaultUnit that decides what unit bare numbers get — see the config reference.)

The clamp, and what page zoom does to vw

Here's the part that surprises people: vw units don't respond to page zoom. Zooming to 200% halves the viewport measured in CSS pixels, so a vw-derived size comes out at the same physical size it was before. A font size expressed purely in vw is, in practice, unzoomable — while WCAG asks for text to survive 200%.

Salty's clamps aren't purely vw. defineViewportClamp emits clamp(min, Xvw, max) with absolute ends, and absolute lengths do scale with page zoom. So when someone zooms in, the fluid middle shrinks past the floor, the floor takes over, and the floor grows with the zoom. Which means minMultiplier isn't only a taste decision — it's the thing standing between a fluid type scale and an unzoomable one:

/styles/helpers.css.ts
import { defineViewportClamp } from "@salty-css/core/helpers";

export const fhdClamp = defineViewportClamp({
  screenSize: 1920,
  minMultiplier: 0.75, // text never drops below 75% of its reference size
  maxMultiplier: 1.25,
});
Example
export const Lead = styled("p", {
  // 20px at the reference width, with an explicit 16px floor for this one
  base: { fontSize: fhdClamp(20, 16) },
});

Then test it, which takes a minute: zoom to 200% and read a paragraph, then narrow the window to 320px — roughly what 400% zoom looks like on a laptop — and check that nothing needs horizontal scrolling to read. Those two passes catch most of what an automated checker never will.