Docs

This page isn't translated yet

Accessibility utilities

sr-only!, the skip link and other accessibility helpers.

On this page

Systhema's base layer ships the pieces every page needs for keyboard and screen-reader users: a .skip-link that appears on focus, a reduced-motion fallback for every animation, and the layout rules that keep landmarks and anchor targets usable. Its components use Tailwind's sr-only! for text only screen readers announce.

Quick referenceLink to this section

The applicationLayout block (on by default, blocks.applicationLayout) emits these base rules:

SelectorStyles
htmloverflow-x: clip;overflow-y: auto;
bodyposition: relative;min-height: 100dvh;display: flex;flex-direction: column;justify-content: space-between;
header, footer, main, sectiondisplay: block;width: 100%;overflow-x: clip;overflow-y: visible;
mainflex-grow: 1;
.containerwidth: calc(100% - var(--container-margin) * 2);max-width: var(--container-width);margin-inline: auto;
.skip-linkposition: absolute;top: 0;left: 0;z-index: 9999;padding: 0;width: 1px;height: 1px;margin: 0;overflow: hidden;clip: rect(0, 0, 0, 0);white-space: nowrap;border: 0;background-color: var(--color-foundations-surface-bg, #ffffff);color: var(--color-foundations-text, #000000);
figurewidth: 100%;margin-inline: auto;
[id]:not(section):not(.figure-w-full):not(.figure-w-screen)scroll-margin-top: var(--section-padding-y, 48px);

A skip link lets keyboard users jump past the header straight to the content (WCAG 2.4.1). .skip-link is visually hidden until it receives focus, then pins itself to the top-left corner above everything else, in the page's foundation colors. Make it the first focusable element on the page and point it at your <main>:

<body>
  <a class="skip-link" href="#main-content">Skip to content</a>
  <header class="header">…</header>
  <main id="main-content">…</main>
</body>

Systhema's RootLayout and page templates already render it this way. The preview focuses the link from a button so you can see the focused state; on a real page, press Tab once after load.

import { useRef } from 'react'
import { Button } from '@systhemaui/next'

export default function Demo() {
  const link = useRef<HTMLAnchorElement>(null)

  return (
    <div className="flex min-h-48 w-full flex-col items-center justify-center gap-4">
      <a ref={link} className="skip-link" href="#skip-link-demo-main">
        Skip to content
      </a>
      <Button.button variant="secondary" onClick={() => link.current?.focus()}>
        Focus the skip link
      </Button.button>
      <main id="skip-link-demo-main" className="text-small color-body">
        Page content
      </main>
    </div>
  )
}

The rule lives in the always-loaded base layer, so it works without a focus:not-sr-only utility that a content scan could miss.

Visually hidden textLink to this section

Use sr-only! (Tailwind's sr-only with !important) for text that only screen readers should read: the label of an icon-only control, or an "(opens in new tab)" cue. The ! keeps component styles from making the text visible again.

Icon-only button
export default function Demo() {
  return (
    <button
      type="button"
      className="flex size-11 items-center justify-center rounded-full border border-(--color-foundations-surface-border) color-heading"
    >
      <svg aria-hidden="true" focusable="false" viewBox="0 0 16 16" className="size-4">
        <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" />
      </svg>
      <span className="sr-only!">Close</span>
    </button>
  )
}

Give each control one label. An aria-label overrides the text inside the control, so don't add both; and mark decorative icons aria-hidden="true" (plus focusable="false" on a raw <svg>).

Reduced motionLink to this section

Under @media (prefers-reduced-motion: reduce):

  • The fade utilities (animate-fadein*, animate-fadeout) and the scroll-reveal classes (aos, animates-on-scroll) snap to their final visible state: no animation, no transform, full opacity. This holds whether or not the scroll-reveal JavaScript runs, so content never stays invisible.
  • .parallax drops its transform and the parallax engines write none.

The escape hatch is emitted at the same specificity as the rule that hides an unrevealed element (.aos:not(.animated):not(.animate-disable)) and after it, because a media query adds no specificity and would otherwise lose the !important cascade. Your own transitions are not covered: add motion-reduce:transition-none where something moves. Details are on Animation, Parallax and Easing and transitions.

Base layout rulesLink to this section

The other rules in the table make the landmarks behave:

  • body is a full-height flex column, and main grows, so the footer sits at the bottom of short pages.
  • header, footer, main and section clip horizontal overflow (overflow-x: clip), so a slide-in animation or a full-bleed figure never adds a horizontal scrollbar, while sticky positioning keeps working.
  • Every element with an id (except sections and full-width figures) gets scroll-margin-top: var(--section-padding-y), so an in-page link lands with some room above the target. With a sticky .header on the page, html and body also reserve the header's height as scroll-padding-top (see CSS variables).

Responsive and state variantsLink to this section

sr-only! takes Tailwind's variants, so md:not-sr-only! shows a label from md up. .skip-link has no variants; it reacts to :focus and :focus-visible.

CustomizingLink to this section

The skip link takes its colors from --color-foundations-surface-bg and --color-foundations-text, so it follows your color tokens. Its corner radius reads --button-border-radius (4px when unset) and its outline width --focus-ring-width (2px when unset); the default tokens define neither, so set them in your own CSS to change them:

src/app/(site)/globals.css
:root {
  --button-border-radius: 999px;
  --focus-ring-width: 3px;
}

These rules are always emitted: blocks.applicationLayout exists in the config type, but core doesn't read it, so false does not remove them.