Skip to content
PolyFish CSS

Typography and accessibility

A ten-step scale and the 66-character measure, three families with metric-matched fallbacks — and what the framework handles for everyone.

Documentation menu

01 / 02

Typography

A ten-step scale, a 66-character measure, three families with metric-matched fallbacks, and the wrapping rules that keep a headline from running off a phone.

The scale

Ten steps from --pf-text-xs (0.8125rem) to --pf-text-5xl. The display sizes are fluid: 5xl is clamp(3.25rem, 1.75rem + 6.2vw, 6rem), so a headline grows with the screen instead of jumping at a breakpoint. Body sizes are fixed, because body copy that changes size as you resize is disorienting rather than responsive.

The whole scale can be re-based by changing the root font size, and the ratio between steps is deliberately narrow: large jumps make a page feel like a poster and read like one. The tokens page has a live control for trying a different scale against real components.

Measure, and why it matters more than size

p and li carry a maximum inline size of --pf-measure, 66 characters. This is the one place the framework constrains content without being asked, and it is the single highest-leverage typographic decision on any page: a line you cannot track back to is a line nobody reads.

.long-read { --pf-measure: 72ch; }   /* looser, for large type */
.dense-ui  { --pf-measure: 52ch; }   /* tighter, for dense panels */

Override it per region rather than globally, and remember that the number is in ch units — it tracks the font, so a wider face automatically gets a wider column in absolute terms.

Three families, and their fallbacks

TokenUsed for
--pf-font-displayHeadings and the brand lockup
--pf-font-sansBody copy, UI, labels
--pf-font-monoCode, tabular figures, eyebrows

Each stack names a metric-matched fallback before the system fonts — "Bricolage Fallback" before "Trebuchet MS", and so on. Those fallbacks are real @font-face declarations with measured size-adjust, ascent-override and descent-override values, which is why the swap from fallback to web font moves nothing and cumulative layout shift measures 0.00 on a throttled phone. If you swap the display face for your own, generate new overrides for it rather than keeping ours.

Wrapping, balance and hyphenation

  • Headings use text-wrap: balance, so a two-line headline splits evenly instead of leaving one word alone on the second line.
  • Body copy uses text-wrap: pretty, which prevents orphans without rebalancing the whole block. Firefox does not support it yet, so treat it as a polish pass rather than a layout you depend on — the paragraph wraps normally there.
  • Below 30em, h1, h2 and h3 may hyphenate. This exists because a single long word at display size is wider than a 320px screen — the page scrolled sideways before it was added. It needs a lang attribute on <html> to work, which every page should carry anyway.
  • Inline code wraps and may break inside a token when the token alone cannot fit a line. It used to be nowrap, which made every long identifier an unbreakable box that widened its container.

Prose blocks

.pf-prose is for a run of authored content — a blog post, a documentation page, anything from a CMS. It sets rhythm between elements so you do not have to put a class on every paragraph, and it constrains width with .pf-measure-prose or .pf-measure-wide.

<article class="pf-prose pf-measure-wide">
  <h2>…</h2>
  <p>…</p>
</article>

Inside a prose block you write plain HTML: headings, paragraphs, lists, tables, code. If your CMS emits markup you do not control, this is the class that makes it presentable without touching the output.

Tips

  • Set the eyebrow and metadata in the mono family. Small text in a display face is where legibility goes first.
  • Use font-variant-numeric: tabular-nums — or .pf-table--numeric — wherever numbers are compared vertically.
  • Do not add weights you will not use. Each one is a font file, and the fallback has to be matched for every one of them.
  • Check headings at 320px before shipping a new display face. That is where the word "Documentation" broke this site.

02 / 02

Accessibility and RTL

What the framework does for you, what it deliberately refuses to do, and the things no stylesheet can do on your behalf. A CSS framework cannot make a page accessible — it can remove a set of standard ways to make one inaccessible.

Two pages that carry most of the practical work

Forms has the checklist for labels, errors and focus, and Right to left covers the lang attribute — which decides the screen-reader voice, and without which the heading hyphenation silently does nothing.

What is claimed, and what is not

A framework cannot make your site accessible — most of it is your content, your headings and your alt text. It can be responsible for its own components, and this is the honest accounting of which WCAG 2.2 AA criteria the framework carries versus which it can only help you meet.

Criterion Who carries it How
1.4.3 Contrast (minimum)FrameworkIn the default theme, every text-on-surface pair is AAA (7:1 or better) in both the dark and light schemes, measured in a browser on each palette change. The example themes are AAA for body text, links, buttons and code; muted text on recessed surfaces is AA on some of them. Retint and it becomes yours — the five pairs to re-check are on the theming page.
1.4.4 Resize textFrameworkFluid type through clamp(), no fixed pixel heights on text containers, zoom to 200% without loss.
1.4.10 ReflowFrameworkNo horizontal scroll at 320px; wide content scrolls inside its own container.
1.4.11 Non-text contrastFrameworkBorders, focus rings and control boundaries meet 3:1.
1.4.12 Text spacingFrameworkSpacing comes from tokens on containers, not fixed heights, so user stylesheets can override it.
2.1.1 KeyboardFrameworkEvery interactive component is a native control: checkbox, radio, details, dialog, button.
2.4.7 Focus visibleFramework:focus-visible ring on every focusable element, never removed.
2.4.11 Focus not obscuredSharedSticky headers use scroll-margin-top so an anchored target is not hidden. A sticky element you add is yours.
2.5.8 Target size (minimum)Framework44px floor under (pointer: coarse), verified in the browser suite.
2.3.3 Animation from interactionsFrameworkEvery animation sits inside a reduced-motion guard and is not applied when motion is reduced.
3.3.1 Error identificationSharedStyling keys off aria-invalid and the message links with aria-describedby. Writing a message that says what is wrong is yours.
4.1.2 Name, role, valueSharedComponents use native elements, and the optional script adds ARIA where a native element cannot express the pattern. Labelling your controls is yours.
1.1.1 Non-text contentYoursThe framework ships no images. Alt text is content.
1.3.1 Info and relationshipsYoursHeading order, table headers and list semantics are decisions you make in markup. The build check on this site enforces them for this site, not for yours.
2.4.2 Page titled, 3.1.1 LanguageYoursBoth live in the document head.

What has not been done yet

No screen reader has been through these components. The ARIA is correct as written and the automated checks pass, but "the attributes are right" and "it is comprehensible to someone using NVDA" are different claims, and only the first one is currently supported by evidence. A pass with NVDA and VoiceOver is one of the things that has to happen before a 1.0.

Handled for you

ConcernWhat the framework does
Focus visibilityA two-pixel accent ring on :focus-visible for every interactive element, declared with :where() so a component can override it without an !important fight.
Motionprefers-reduced-motion drops every transition and animation in every module to 0.01ms and disables smooth scrolling. A new component cannot forget to honour it.
ContrastEvery role pairing is verified at AA or better in both themes, and re-verified on every build.
Touch targets44px minimum on coarse pointers, without inflating the desktop layout.
ZoomNo page blocks zooming, and fields are floored at 16px on touch so iOS does not zoom on focus and strand you there.
High contrast modeforced-colors reinstates borders and honours system keywords, so buttons and cards do not flatten into the page.
Skip link.pf-skip is off-screen until focused, then lands on top of the navigation.
Hidden content.pf-visually-hidden uses clip-path rather than display: none, so the text stays in the accessibility tree.

Focus you can actually see

Tab through the two panes below. The right one has the ring removed — the single most common accessibility regression in a redesign, and usually introduced on purpose because someone thought the outline was ugly.

:focus-visible ring

Appears for keyboard users, stays out of the way of mouse clicks.

outline: none

Keyboard focus is now invisible. There is no way to tell where you are on the page.

Contrast, measured rather than eyeballed

Both palettes are checked on every build. These are the numbers as shipped.

PairingDarkLightRequirement
Body text on page10.3514.774.5 (AA)
Muted text on page5.496.724.5 (AA)
Accent link on page6.706.194.5 (AA)
Label on accent button9.317.164.5 (AA)

This is also why the accent darkens in light mode rather than staying put: the pastel green reads 6.7:1 on deep blue and 1.6:1 on near-white. A palette that does not change with its background is decoration, not a palette.

See the guards working

Three of the accommodations in this module are invisible until the condition applies. These switches apply them on demand, so you can check the behaviour without changing your operating system settings.

overlays

Hairlines and muted text

Turn on the contrast switch: this border goes solid and this copy moves up to near-full strength. Those are the exact values the prefers-contrast: more block declares.

The ring comes from :focus-visible, so it appears for keyboard focus and stays out of the way of a mouse click.

Checkboxes and :has(). The overlay draws a 44px box centred on each control — the minimum a finger needs.

Still yours

No stylesheet can do these, and a framework that implies otherwise is selling something.

  • Heading order. The framework styles h1 to h6; it cannot know whether your h3 should have been an h2. One h1 per page, no skipped levels.
  • Accessible names. Every control needs a <label for>, an aria-label or an aria-labelledby. An icon button without one announces as "button" and nothing else.
  • Alt text. Empty alt="" for decoration, a description for anything that carries meaning. There is no third option, and a missing attribute is not the same as an empty one.
  • Landmarks. <header>, <nav>, <main>, <footer>, and an aria-label on a second nav so the two can be told apart.
  • Language. <html lang> decides which voice a screen reader uses. Getting it wrong makes a page unintelligible rather than merely wrong.
  • Link text. "Read more" four times on a page is four identical entries in a screen reader's link list.

Attributes that carry the styling

Several components style themselves from ARIA state rather than a modifier class. This is deliberate: a class and an attribute can disagree, and when they do it is the attribute that assistive technology believes.

AttributeStyles
aria-currentNavigation underline, pagination fill, sidebar and list-group highlight
aria-invalidField border and focus ring colour
aria-pressedSelected button in a group
aria-disabledDimmed pagination item, without removing it from the tab order the way disabled does
aria-busyButton spinner and pointer lock
aria-sortSort direction marker on a table header

Known limits

Three places where the CSS-only version is honestly weaker, all documented in the source of the component rather than here alone.

Tabs

Without the script they announce as a radio group, not a tablist. Arrow keys work either way. data-pf-tabs adds the full pattern.

Dialogs

The :target fallback does not trap focus. Use the native <dialog> whenever a script is available — it traps focus and handles Escape itself.

Menus

A <details> menu does not close on an outside click without a listener, and it is not an ARIA menu. For a true menu widget you need the script and the roles.

Automated checks are a floor, not a ceiling

Everything on this page is verified by a script on every build, which catches contrast, missing labels, duplicate ids, heading counts and dead anchors. It cannot tell you whether your alt text is useful, whether your tab order makes sense, or whether the page is usable at 200% zoom. Test with a keyboard and a screen reader before you ship.

Right to left and i18n

Most "RTL support" is a second stylesheet that mirrors the first. This framework has no such file, because it never wrote a physical direction in the first place. Set dir="rtl" and the layout mirrors — margins, padding, borders, offsets, alignment, and the direction every component opens in.

Logical properties, everywhere

The rule is absolute inside the framework: no margin-left, no padding-right, no left or right, no text-align: left. Their logical equivalents describe the start and end of a line rather than a side of the screen.

PhysicalLogical
margin-leftmargin-inline-start
padding-rightpadding-inline-end
border-leftborder-inline-start
left: 0inset-inline-start: 0
width / heightinline-size / block-size
text-align: lefttext-align: start

The checker enforces this on every build, including the longhands. That detail matters: border-left-color slipped past an earlier version of the rule and three alert variants coloured the wrong edge under dir="rtl" for several releases. If you write your own rules, the same discipline is what keeps a mirrored page correct.

Setting a page up

<html lang="ar" dir="rtl">

That is the whole configuration. There is no class to add, no stylesheet to swap and no build flag. For a single mirrored region inside an otherwise left-to-right page, put dir on that element instead — the attribute inherits, and the logical properties follow it.

There is a complete mirrored page in the examples: right to left, using the same markup and the same classes as its left-to-right counterpart.

Why lang is not optional

  • Hyphenation only works when the browser knows the language. The framework hyphenates large headings below 30em to stop a long word running off a phone, and that silently does nothing without lang.
  • Screen readers choose a voice and pronunciation rules from it. An English voice reading French is close to unusable.
  • Quotation marks, date formats and the correct font for a script all depend on it.
  • Mark a passage in another language inline: <span lang="fr">.

Type across scripts

The bundled faces cover Latin only. For Arabic, Hebrew, Greek, Cyrillic, or CJK, add a face that covers the script and put it first in the family token — do not rely on the fallback chain to find something reasonable.

:root[lang="ar"] {
  --pf-font-sans: "Noto Sans Arabic", system-ui, sans-serif;
  --pf-font-display: "Noto Kufi Arabic", var(--pf-font-sans);
  --pf-measure: 58ch;   /* scripts differ in average character width */
}

Two things to re-check per script: the measure, because 66 characters of Arabic or Japanese is not the same line length as 66 of English; and the line height, because scripts with taller ascenders and descenders need more room than a Latin-tuned 1.65.

Numbers, dates and figures

Tabular figures (.pf-table--numeric) assume the font has them, which is true of the bundled mono face and not necessarily of a script-specific face you add. Format dates and numbers with Intl on the server or at render time rather than styling around a fixed format, and remember that numerals inside RTL text still run left to right — that is correct, not a bug to fix with CSS.

Checking your own work

Flip the page and look at it. Most mirroring bugs are visible in seconds: an icon on the wrong side of a label, a border on the wrong edge, an arrow pointing the wrong way. The things to look at first are anything with a direction — chevrons, progress, breadcrumb separators, and any illustration containing text.

document.documentElement.dir = 'rtl';

Paste that in a console on any page. If something does not mirror, the cause is a physical property — in your CSS, or in ours, in which case it is a bug worth reporting.