Skip to content
PolyFish CSS

Installation and modules

Copy the files, get the load order right, and see what lives in each module — fonts, folder layout and a production server config included.

Documentation menu

01 / 02

Installation

There is nothing to install. Copy the CSS files into your project, link the ones your page needs, and write HTML. Twelve CSS modules, one of them required, plus an optional script.

Get the files

Download them individually or take the concatenated bundle while prototyping.

ModuleSourceMinifiedMin + gzipRequired
Core · min52.5 KB20.7 KB5.3 KBAlways
Headers · min25.5 KB10.4 KB2.6 KBWith a nav or hero
Content · min45.0 KB19.5 KB4.3 KBPage body components
Forms · min15.7 KB5.8 KB1.6 KBBeyond the four basic controls
Components · min47.3 KB20.8 KB4.2 KBApp UI: badges, tabs, menus, lists
Footers · min9.9 KB4.6 KB1.2 KBWith a footer
Space · min8.3 KB2.5 KB0.6 KBPadding, margin and gap utilities
Effects · min18.8 KB9.4 KB1.8 KBFrost, gradients, animations
Utilities · min20.3 KB9.0 KB2.7 KBOptional helpers
Variants · min4.2 KB1.8 KB0.6 KBCurated hover, focus and active states
Classless · min9.1 KB6.1 KB1.5 KBStyles bare semantic HTML
Fonts · min8.0 KB3.3 KB0.6 KBSelf-hosted @font-face
polyfish.js · min24.4 KB9.9 KB3.7 KBOptional enhancements
Bundle · min244.0 KB102.1 KB18.7 KBThe nine core modules

Two builds, same rules

The source files are heavily commented — every component carries its markup example and the reasoning behind the technique — which is why they are roughly twice the size of the minified build. Ship .min.css, read the source. Both were parsed and confirmed to contain an identical number of rules.

Fonts are optional and self-hosted

PolyFishCSS-Fonts.css declares seven @font-face rules pointing at /fonts, about 167 KB of latin-subset woff2. There is no request to a font CDN anywhere on this site, which is what lets the Content-Security-Policy refuse every remote origin outright. Skip the file and the system stacks take over.

Tip: start with the bundle, split later

If you are not sure which modules a page needs, link PolyFishCSS.min.css and get on with building. Splitting into individual files is a five-minute change once the page exists, and the module picker on the modules page writes the link block for you. Migrating an existing site? The migration guide has the order of work.

Or from npm

npm i polyfishcss ships the same files — one importable path per module, no build step hidden in the package, no plugin to configure. The optional script resolves to a typed ES module; the bundler and framework examples are on the JavaScript page.

main.tsts
import "polyfishcss";            // the whole bundle — or only what a page needs:
import "polyfishcss/core";
import "polyfishcss/content";
import "polyfishcss/utilities";   // utilities last, as always

Load order

Core first, always. Headers, Content and Footers can appear in any order between them. Utilities goes last so a helper class beats a component rule at equal specificity.

headhtml
<link rel="stylesheet" href="/css/PolyFishCSS-Core.css">
<link rel="stylesheet" href="/css/PolyFishCSS-Headers.css">
<link rel="stylesheet" href="/css/PolyFishCSS-Content.css">
<link rel="stylesheet" href="/css/PolyFishCSS-Footers.css">
<link rel="stylesheet" href="/css/PolyFishCSS-Utilities.css">
<link rel="stylesheet" href="/css/your-theme.css">  <!-- overrides -->

Core is not optional

The other four files read custom properties that Core defines. Without it they compute to nothing and the page renders unstyled.

Fonts

PolyFish names three families — Bricolage Grotesque for display, IBM Plex Sans for body, IBM Plex Mono for code — and ships them as latin-subset woff2 files you serve yourself. There is no request to a font CDN anywhere in the framework or on this site.

headhtml
<!-- the two faces above the fold -->
<link rel="preload" as="font" type="font/woff2" crossorigin
      href="/fonts/ibm-plex-sans-latin-400-normal.woff2">

<link rel="stylesheet" href="/PolyFishCSS-Fonts.css?v=0d3ddd95">

Copy the /fonts directory alongside the CSS. The crossorigin attribute on a font preload is not optional — fonts are fetched in CORS mode, and a preload without it downloads the file twice.

Skip the module entirely if you would rather use system faces. Core's stacks fall back to Trebuchet, system-ui and ui-monospace; the type scale, spacing and layout are unchanged, only the letterforms differ.

A starter page

Everything above in one copy-paste: the two stylesheet links, a skip link and a section with a headline, a lede and a button. It is a working page as it stands — replace the copy.

index.htmlhtml
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My page</title>
<link rel="stylesheet" href="/PolyFishCSS-Fonts.css?v=0d3ddd95">  <!-- skip for system faces -->
<link rel="stylesheet" href="/PolyFishCSS.min.css?v=ba1e8e54">
</head>
<body>
<a class="pf-skip" href="#main">Skip to content</a>
<main id="main">
  <section class="pf-section">
    <div class="pf-container pf-measure-prose">
      <h1>A page that needs nothing else</h1>
      <p class="pf-lede">Two files, no build step, no JavaScript.</p>
      <a class="pf-btn" href="/docs">Read the docs</a>
    </div>
  </section>
</main>
</body>
</html>

The bundle is the right default while a page is still taking shape. When it settles, the picker below swaps the single link for the four or five modules it actually uses.

Pick your modules

Tick what your page contains. The link block below is what goes in your <head> — Core is not a checkbox because it is not optional.

this page has…
headyour selection
<!-- required, 20 KB -->
<link rel="stylesheet" href="/PolyFishCSS-Core.min.css?v=37ca963d">
<link rel="stylesheet" href="/PolyFishCSS-Headers.min.css?v=8528ac89">   10 KB<link rel="stylesheet" href="/PolyFishCSS-Content.min.css?v=f4d620bb">   19 KB<link rel="stylesheet" href="/PolyFishCSS-Forms.min.css?v=0a45cc83">     6 KB<link rel="stylesheet" href="/PolyFishCSS-Components.min.css?v=d44f2497"> 20 KB<link rel="stylesheet" href="/PolyFishCSS-Footers.min.css?v=3bf8c0cc">    5 KB<link rel="stylesheet" href="/PolyFishCSS-Space.min.css?v=8a026ac1">     2.5 KB<link rel="stylesheet" href="/PolyFishCSS-Effects.min.css?v=3ce615f5">   9 KB<link rel="stylesheet" href="/PolyFishCSS-Utilities.min.css?v=5e5462f7"> 8.5 KB<link rel="stylesheet" href="/PolyFishCSS-Variants.min.css?v=352c6caa"> 2 KB<link rel="stylesheet" href="/PolyFishCSS-Classless.min.css?v=ced64c3c"> 5 KB

That is six or more files — ship PolyFishCSS.min.css instead. It is 18.2 KB gzipped in one request, smaller than the same modules fetched separately.

Checkboxes and :has(). Sizes are per-file minified; the gzipped total is on the table above.

When to use the bundle

Loading only the modules a page uses is the right default — but not past a point, and the point is measurable. Gzip finds far more repetition inside one file than across the same modules compressed separately, so the bundle overtakes individual modules at six:

Modules loadedSeparate files, gzippedRequests
4 — Core, Headers, Content, Footers13.4 KB4
6 — plus Forms and Components19.2 KB6
7 — plus Space19.8 KB7
8 — plus Effects21.6 KB8
All nine core modules, as the bundle18.7 KB1

So: five files or fewer, link them individually. Six or more, ship PolyFishCSS.min.css — it is smaller and one request. This site follows its own rule: the documentation pages use the bundle, the example pages link four to six modules each.

Your CSS always wins

Every module is wrapped in a cascade layer, and each file declares the full layer order, so it holds regardless of which order you link them in. Unlayered CSS beats layered CSS whatever the specificity, which means your own stylesheet overrides any framework rule with a plain class selector.

headhtml
<link rel="stylesheet" href="/PolyFishCSS-Core.min.css?v=37ca963d">
<link rel="stylesheet" href="/app.css">  <!-- wins, always -->

No !important, no specificity arms race, and no worrying about whether Utilities loaded last. If you would rather layer your own CSS too, name a layer after the framework's:

app.csscss
@layer polyfish, app;

@layer app {
  .pf-card { background: rebeccapurple; }
}

Right to left

Nothing in the framework uses a physical direction. Margins, padding, borders, positioning offsets and text alignment are all logical properties, so a document with dir="rtl" mirrors itself — no second stylesheet, no overrides, no [dir] selectors.

htmlthat is the whole change
<html lang="ar" dir="rtl">

The right-to-left example is the same components with Arabic copy and that one attribute. A build check fails if a physical property such as margin-left ever reappears in a module, because that is easy to write by habit and invisible until somebody reads the site in Arabic or Hebrew.

Two notes. Values that are genuinely left-to-right — an email address, a version number, a code sample — take dir="ltr" on the element that holds them. And the directional spacing utilities are .pf-ms-*, .pf-me-*, .pf-ps-* and .pf-pe-* for start and end; the old ml/mr/pl/pr names were removed in 0.20.

Start with no classes at all

Classless frameworks have a real following for documentation, prototypes and small sites: one link and semantic HTML looks finished. Their usual limit is that they stop there — the first card grid means switching frameworks.

PolyFishCSS-Classless.css is the on-ramp instead of the destination. With Core and this file loaded, bare headings, links, buttons, fields, tables, code blocks, quotes, disclosures and dialogs look right. Then add PolyFish components wherever a page needs more, and they win automatically: every classless rule has zero specificity through :where(), sits in the lowest cascade layer, and only touches elements that carry no class at all.

index.htmlthe whole setup
<link rel="stylesheet" href="/PolyFishCSS-Core.min.css?v=37ca963d">
<link rel="stylesheet" href="/PolyFishCSS-Classless.min.css?v=ced64c3c">

<main>
  <h1>Release notes</h1>
  <p>Plain HTML, styled.</p>
  <button>Subscribe</button>
</main>

What PolyFish does that the others do not

Each popular framework is excellent at one thing and asks you to accept a trade for it. This is what PolyFish combines, and where each idea came from.

FeatureBootstrapTailwindPicoBulmaPolyFish
ComponentsYesNo (add a library)MinimalYesYes
Utilities with state variantsSome, no hoverYes, via compilerNoSomeYes, no compiler
Classless modeNoNoThat is the productNoOptional module
Modals and menusJavaScriptBring your ownJavaScript for modalsJavaScriptNative popover and invoker commands
Build stepOptional (Sass)RequiredNoneOptional (Sass)None
Load only what you useVia SassPurge at buildOne fileVia SassTwelve files, link what you need
Your CSS wins without !importantNoPartlyNoNoCascade layers
Right to leftSeparate RTL buildLogical utilitiesPartlyPartlyLogical throughout, one file
Retint at runtimeCustom properties, partlyRebuildCustom propertiesRebuildEleven role tokens
Works under a strict CSPYesYesYesYesYes, and data values never need inline styles

Where a framework is better at something, the table says so — Tailwind's variant coverage is far broader, and Pico is smaller if classless is all you need.

Coming from Tailwind

Tailwind is the most-used CSS framework there is, and it earned it. Developers who stay with it name the same few things: a constrained scale that stops a codebase growing sixty-nine font sizes, styles co-located with markup, and above all the variant system — hover:, focus:, dark:, md:. Developers who leave name a different few: class strings too long to read, abbreviations you have to memorise, a compiler in the way of every change, and a new layer of component libraries rebuilt on top because the utilities alone were not enough.

PolyFish keeps what people stay for and removes what they leave over.

What people sayTailwindPolyFish
"The scale keeps us consistent"Design tokens in a configThe same, as custom properties you can read in the browser and retint without a rebuild
"The variants are why I stay"hover:bg-sky-700, via the compilerpf-hover-bg-accent, plain CSS, no compiler. Nine states, twenty-six utilities
"Responsive prefixes are great"md:hiddenpf-md-hidden — same idea, one naming convention across states and breakpoints
"The markup is unreadable"Twenty utilities on a buttonpf-btn pf-btn--secondary. Components for things you repeat, utilities for the one-off
"I have to memorise abbreviations"p-4, tracking-tight, shrink-0Components are spelled out. Spacing utilities are still short — pf-mt-4 — because that is where brevity earns its keep
"The build step is friction"Required, to purge and to generate variantsNone. You load only the modules you need instead of purging the ones you did not
"It lives in my bundler"PostCSS plugin, a config file, classes scanned from sourcenpm i polyfishcss — every module is an importable subpath and the script is a typed ES module, nothing to configure
"We ended up installing shadcn anyway"Utilities, no componentsComponents included, and they work without JavaScript
"It fights the cascade"Flattens everything to one specificityUses the cascade: every module is in a layer, so your unlayered CSS wins
"Arbitrary values are an escape hatch"w-[37px]Not provided. An arbitrary value in markup is an inline style, and a strict policy forbids those — write a class

A translation table for the common ones

TailwindPolyFish
flex flex-col gap-4pf-stack
flex flex-wrap gap-2 items-centerpf-cluster
grid grid-cols-1 md:grid-cols-3 gap-6pf-grid pf-grid--2
max-w-7xl mx-auto px-4pf-container
mt-4, p-6, gap-3pf-mt-4, pf-p-6, pf-gap-3
md:hiddenpf-md-hidden
hover:shadow-lgpf-hover-shadow-lg
focus-visible:ringpf-focus-ring
group-hover:translate-x-1pf-group-slide on the child, pf-group on the parent
dark:hiddenNo equivalent — the roles flip with the scheme, so a scheme-specific class is rarely needed. Write :root[data-pf-theme="dark"] .thing for the exception.
w-1/2pf-w-50
truncatepf-truncate
sr-onlypf-visually-hidden

The full list is in the class reference. Variants live in their own optional module, PolyFishCSS-Variants.css, loaded after Utilities.

When it breaks

Nine things go wrong, and eight of them are the same three causes. Symptoms first, because that is what you have when you arrive here.

SymptomCauseFix
A component is unstyled, everything else looks right Its module is not linked Check which file owns it in the class reference. Every class lists its module.
Utilities do nothing Utilities is linked before the module it is meant to override Core first, Utilities last. The order in the load order is not a style preference.
Your own CSS is being ignored Almost never the cascade — the framework is layered, so unlayered rules win Check the selector actually matches, then check your stylesheet is linked after. If you wrapped your CSS in @layer, it is now competing on the framework's terms.
The page scrolls sideways on a phone A wide child in a grid track — usually a table or a code block Wrap tables in .pf-table-wrap. If it is your own component, give it min-width: 0; that is what Core does for its own primitives.
iOS zooms when a field is focused and never zooms back The field's font-size is under 16px Do not override .pf-input font-size. Core floors it at 16px under (pointer: coarse) precisely for this.
Colours changed for some visitors and not others Assets cached immutably at a URL that did not change Version the URL. Use a content hash, not a number you type — see performance.
Fonts fall back to system faces The woff2 paths in PolyFishCSS-Fonts.css are relative to that file Keep fonts/ beside the CSS, or edit the five src lines. Also ship the licence files — it is a requirement, not a courtesy.
The theme toggle does nothing The script is not loaded, or the button lacks data-pf-theme-toggle The toggle is the one piece of chrome that needs the optional file. Everything else on the page works without it.
A dialog will not open It genuinely needs JavaScript Load polyfish.min.js and add data-pf-dialog. This is the only component with that dependency, and it is listed as such in the capability tables.

If it is none of those

Open the page with only PolyFishCSS-Core.min.css linked and add modules back one at a time. Two thirds of the reports that reach me are a missing module or a load-order problem, and that bisect finds both in about a minute.

The optional script

PolyFish renders and functions with no script at all. One optional file upgrades a handful of components — closing a menu on an outside click, giving the tabs a real ARIA tablist, counting characters in a textarea, sorting a table column.

headhtml
<script src="/polyfish.min.js?v=ef19ac2b" defer></script>

Nothing changes until you mark an element up with a data-pf-* attribute. Full list on the JavaScript page.

Folder layout

Nothing is path-dependent. A conventional layout:

projecttree
/
  index.html
  about.html
  /css/
    PolyFishCSS-Core.css
    PolyFishCSS-Headers.css
    PolyFishCSS-Content.css
    PolyFishCSS-Footers.css
    PolyFishCSS-Utilities.css
    your-theme.css
  /examples/
  /docs/

Serving it

Any static host works. If you want extensionless URLs — /about rather than /about.html — the server needs to resolve the extension itself. This site's own config is published as a starting point:

nginxconf
location / {
    # /about → /about.html, /docs → /docs/index.html
    try_files $uri $uri.html $uri/ =404;
}

The complete server block, including the redirect that strips .html from old URLs, is in nginx/polyfish.org.conf.

02 / 02

Modules and architecture

Nine core modules, each mapping to a part of a page, plus three optional ones. Take Core plus whatever your page actually contains — a marketing page needs four, a sign-in screen needs two.

FileCoversSizeNeeds Core
CoreTokens, reset, type, layout, buttons20 KBIs Core
HeadersTop bar, nav, heroes, page headers10 KBYes
ContentSections, cards, tables, forms, code19 KBYes
FormsFloating labels, validation, range, file6 KBYes
ComponentsBadges, tabs, menus, lists, dialogs20 KBYes
FootersFooter layouts and link columns5 KBYes
FontsSelf-hosted @font-face declarations3 KBIndependent
ClasslessStyles bare semantic elements, no classes5 KBIndependent
SpacePadding, margin, gap and width utilities2.5 KBYes
EffectsFrost, gradients, animations, group hover9.4 KBYes
UtilitiesDisplay, sizing, position, visibility helpers8.5 KBYes
VariantsCurated hover, focus, active and group states2 KBYes

A class is declared in exactly one module

That is what makes partial loading safe, and the build fails if two modules declare the same class. Architecture explains the boundaries and the rest of the rules the build enforces.

What you get for what you load

Tick the modules you would link. Every component below that needs one you have not ticked is marked unavailable — this is the framework's dependency graph, made pokeable. Core is not a checkbox because nothing works without it.

modules linked

Buttons, type, layout

Every button variant, the type scale, container, stack, cluster, grid and split.

Core — always on

Nav, hero, page head

Announcement bar, sticky navigation with the mobile menu, four hero variants, breadcrumbs.

needs Headers

Sections, cards, tables

Section rhythm, cards, plans, stats, tables, alerts, code blocks, accordions, sidebars.

needs Content

Floating labels, validation

Input sizes, floating labels, feedback messages, fieldsets, range, file inputs, option cards.

needs Forms

Tabs, menus, dialogs

Badges, button groups, pagination, progress, lists, avatars, switches, tabs, menus, dialogs, toasts.

needs Components

Footers

Column, slim and accent footers, link columns, social row, legal bar, back-to-top.

needs Footers

Padding and margin utilities

Top and bottom margins, padding, gaps, proportional widths and flex sizing.

needs Space

Frost, gradients and animation

The frosted panel, gradient surfaces and text, four animations, scroll reveal, group hover.

needs Effects

Hover and focus accents

Curated state classes: hover background, foreground, border, underline, shadow and lift; focus rings; group hover and focus.

needs Variants

Bare semantic HTML

Headings, links, buttons, fields, tables, code and quotes styled with no classes at all.

needs Classless

Checkboxes and :has(). A dimmed tile is a component you would be writing markup for that has no styles behind it.

What is inside each file

The composer above shows what a module unlocks. This is the inventory: every component a module owns, so you can answer "which file do I need for X" without loading the reference.

Module Rules Owns
Core78Tokens and roles, reset, fluid type scale, .pf-container, .pf-stack, .pf-cluster, .pf-grid, .pf-split, .pf-shell, .pf-screen, buttons and every variant, .pf-logo, .pf-lockup, .pf-invert, .pf-stretched-link, .pf-skip, focus handling, reduced-motion and forced-colors blocks
Headers37.pf-topbar, .pf-nav with the checkbox menu, four .pf-hero variants, .pf-pagehead, .pf-breadcrumb
Content96.pf-section and its rhythm variants, .pf-card, .pf-plan, .pf-stats, .pf-table and six variants, .pf-alert, .pf-code, .pf-accordion, .pf-sidenav, .pf-gallery, .pf-scroller, .pf-steps, .pf-cta, .pf-quote, .pf-prose
Forms37Input sizes, .pf-float, .pf-feedback, .pf-fieldset, .pf-range, .pf-file, .pf-choice-card, .pf-form__row, .pf-counter
Components104.pf-badge, .pf-chip, .pf-btn-group, .pf-pagination, .pf-progress, .pf-meter, .pf-spinner, .pf-list, .pf-avatar, .pf-switch, .pf-input-group, .pf-tabs, .pf-menu, .pf-dialog, .pf-drawer, .pf-toast, .pf-tooltip, .pf-ratio, .pf-skeleton, .pf-empty, .pf-toolbar
Footers16.pf-footer and its column, slim and accent variants, link columns, social row, legal bar, .pf-totop
Classless0Element selectors only — headings, links, buttons, fields, tables, code, quotes, <details>, <dialog>, every rule in :where() at the lowest layer. No classes.
Space76Top and bottom margins, padding, gaps, proportional widths and flex sizing, with responsive gap at two breakpoints
Effects26The frosted panel, gradients, four animations, scroll reveal, .pf-group hover and isolation
Utilities188Display, sizing, position, typography, colour and visibility helpers, container-query helpers, scroll reveal, and the print stylesheet
Variants20Curated state variants: .pf-hover-* background, foreground, border, underline, shadow and lift; .pf-focus-* and .pf-focus-within-* ring and border; .pf-active-* sink and scale-down; .pf-group-hover-* and .pf-group-focus-*
Fonts0Nine @font-face declarations plus three metric-matched fallback faces. No classes.

Rules on which module owns what

  • One owner per class. A component is declared in exactly one file. Other modules may touch it — Core's mobile guards do, Utilities' print block does — but only one defines what it is, and the build fails on two owners.
  • A module never depends on another module's classes, only on Core's tokens. Load Components without Content and the badges still work.
  • Load order is Core first, Utilities last. Between those two it does not matter, because no module overrides another.
  • Every module declares the cascade layer order, so that ordering holds even if you link them out of sequence.

Core

The only required file. It declares every custom property the others read, resets the document, sets the type scale, and provides the layout primitives and buttons.

  • Layout: .pf-container, .pf-stack, .pf-cluster, .pf-grid, .pf-split, .pf-shell
  • Brand: .pf-logo, .pf-lockup
  • Buttons: .pf-btn with --secondary, --ghost, --orange, three sizes, --block, --square
  • Marks: .pf-pill, .pf-mark, .pf-dot, .pf-facet
  • Accessibility: .pf-skip, .pf-visually-hidden, a global :focus-visible ring, and a reduced-motion block

There is no Grid module

No twelve-column grid. .pf-row, .pf-col-*, the offsets and the per-row gutters were 114 classes that pinned proportions to viewport widths; the layout primitives in Core carry their own thresholds instead, and the components page maps every old class to its replacement. The write-up goes deeper into why.

.pf-grid--2 is one class and handles its own breakpoints; an explicit column system is grid-template-columns on your container plus a span per child, two lines you own outright.

Headers

Everything above the fold. The navigation's mobile menu is a checkbox and a sibling selector, so the module needs no script.

  • .pf-topbar — announcement strip, plus a --green variant
  • .pf-nav — sticky and blurred by default; --static and --flush variants
  • .pf-hero — --split, --center and --photo variants, with the mesh background masked in
  • .pf-pagehead and .pf-breadcrumb for interior pages

See it in isolation on the headers example.

Content

The largest module, covering everything between the header and the footer.

  • Sections and section heads, eyebrows, ledes
  • Cards, feature grids, checklists, plan cards, stats
  • Tables that scroll horizontally rather than squashing
  • Forms — inputs, selects with a styled arrow, textareas, checkboxes, inline forms
  • Alerts, quotes, code blocks, accordions built on <details>, galleries, steps and CTA bands

See it in isolation on the page core example.

Forms

Content styles four controls. Forms handles everything a real form needs after that: input sizes, floating labels, validation states and feedback messages, fieldsets and legends, range sliders, file inputs, selectable option cards, form layout rows and character counters.

Two decisions worth knowing about. Validation styling hangs off aria-invalid rather than a class, so the visual state and the announced state cannot disagree. And it uses :user-invalid rather than :invalid — the latter matches an empty required field on page load, which paints a form red before anyone has typed a character.

Components

Eighteen pieces for the screens behind a login: badges and tags, button groups, pagination, progress bars and segmented meters, spinners, list groups, avatars and stacks, switches, input groups, tabs, disclosure menus, dialogs, tooltips, ratio boxes, skeletons, empty states, dividers and toolbars.

Still nothing required. Tabs are radio inputs, the menu is a <details>, the dialog is a native <dialog> with a :target fallback. Two components carry an honest limit in their source comments — the menu does not close on an outside click, and the tabs announce as a radio group rather than a tablist. Both need a listener to go further, and the framework does not pretend otherwise.

Footers

Three footers in 4 KB: the column footer, a one-line --slim variant for docs and app shells, and a full-bleed --green variant that recolours its own links, social row and back-to-top link.

See it in isolation on the footers example.

Fonts

Seven @font-face rules pointing at /fonts, covering the three families Core names. Independent of every other module — load it or don't, before Core or after, it only declares faces. Skipping it falls back to system stacks with no layout change.

Self-hosting is a security decision before it is a performance one: with no font CDN in the page, the Content-Security-Policy can refuse remote origins outright.

Space

Vertical rhythm and sizing, not a spacing utility for every side: top and bottom margins, all-side padding, gaps, proportional widths and flex sizing, with responsive gap at two breakpoints. .pf-mt-6, .pf-p-4, .pf-gap-3, .pf-w-50.

The suffix is a step on the space scale, not a pixel value, so retuning --pf-space-* retunes every utility with it. Negative margins are deliberately absent — a negative margin is nearly always a layout fighting its container, and a utility makes it easy to reach for twice.

It is 6.6 KB minified and 1.3 KB gzipped. It used to be three times that: margin and padding utilities for every side and axis at five breakpoints, plus a column set that duplicated .pf-grid. Gap and the layout primitives made nearly all of it redundant, and nothing else depends on this module — if you would rather not carry a utility surface at all, leave it out

Effects

Transforms composed through custom properties so translate, rotate and scale can be combined without the last declaration silently winning. Filters and a @supports-guarded frosted panel. Gradients built from role tokens, so they follow a theme. Rings with a real offset. Six named animations, all opt-in.

It also carries the pattern a utility framework needs and CSS has no shorthand for: .pf-group plus .pf-group-show, a child reacting to its parent's hover or focus. That reveal is scoped to (hover: hover) — on a touch device the affordance stays visible rather than hiding behind a state a finger cannot enter.

Every animation sits inside prefers-reduced-motion: no-preference rather than merely being slowed by Core's global block. An animation that starts at opacity: 0 and is slowed to nothing leaves the element invisible forever; not applying it at all is the only correct behaviour.

Variants

Twenty curated state classes — .pf-hover-*, .pf-focus-*, .pf-active-* and the group variants — in 2 KB, rather than a variant for every utility in every state. Loading nothing is the default; loading this is opting the page into interactive accents without a build step.

Classless

The on-ramp, not the destination: bare semantic HTML rendered properly with zero classes. Every rule sits in :where() at the lowest cascade layer, so a .pf-* class placed on top always wins. The full write-up is in start with no classes at all.

Utilities

Single-purpose helpers reading the same tokens: margins and gaps on the 4px scale, flex alignment, text sizes, role colours, two responsive visibility classes and a print block that hides navigation, footers and CTA bands.

This is the first file to drop if your project already has its own utilities — nothing else depends on it.

Rule of thumb

If you cannot point at the element on your page that needs a module, don't load it. A sign-in screen needs Core and Content; a docs page needs Core, Headers and Content.

Architecture

This page is for people who want to change the framework rather than use it: how the files are divided, what rules the build enforces, and why each of those rules exists. Nearly every one was added after something broke.

Module boundaries

Twelve files. Core is required and owns the tokens, the reset, the type scale, layout primitives, buttons and focus. Eight more map to parts of a page. Three are optional: Fonts, Variants and Classless.

The rule that keeps them honest: a class is declared in exactly one module. The build fails if two modules declare the same class, because a page that loads one and not the other would then get half a component. Which module owns a class is listed for every class in the reference.

Cascade layers, and what they cost

Every module is wrapped in a layer, declared in the same order at the top of every file:

@layer polyfish.core, polyfish.classless, polyfish.headers,
       polyfish.content, polyfish.forms, polyfish.components, polyfish.footers,
       polyfish.space, polyfish.effects, polyfish.utilities, polyfish.variants,
       polyfish.fonts;

The benefit is the headline one: your unlayered CSS beats all of it without !important. The cost is internal, and it is not obvious — specificity stops being the tiebreaker between modules. A rule in polyfish.core loses to one in polyfish.content however specific it is, silently, because the losing rule is perfectly valid CSS that simply never applies.

This shipped twice: a 16px touch floor for form fields, and the .pf-invert background flip. Both now live in the module that owns the component, and where that is impossible the declaration names the layer it writes into and says why. The build enforces the field case directly.

Two layers of tokens

Palette tokens name a position, never a hue: --pf-base-800, --pf-hue-1. Role tokens name a job: --pf-bg, --pf-text, --pf-accent, --pf-ink.

Components read roles. Only roles. The build fails on a component rule that reads a palette token, a literal copy of a default palette colour, or declares a custom property outside :root from a palette value. That last rule was added after --pf-btn-fg: var(--pf-base-950) put dark text on a dark button inside an inverted panel — a token declaration looks like the right place for a palette value, and inside a component it is not.

A third group is derived: hover, wash, line and ring colours are computed from the accent with color-mix(). Hover mixes toward --pf-text rather than toward black, so it can only raise the contrast of text on the accent.

Naming

  • pf- prefix on everything, so the framework collides with nothing.
  • block__element--modifier: .pf-card__title, .pf-card--flat.
  • A modifier never exists alone. .pf-card--flat is meaningless without .pf-card.
  • Utilities are property-value: .pf-mt-5, .pf-fg-muted. Responsive ones take a breakpoint prefix.
  • Renames and removals are listed in the migration guide.

What the build refuses to ship

tools/verify.py is the standing record of every bug worth not repeating. The rules that exist because something broke:

RuleThe failure it came from
Components read roles onlyFive of six themes hovered to the default green
No token read that nothing declares--pf-ink lived only in the optional Compat file
No palette values in component-scoped tokensDark text on a dark button inside .pf-invert
Touch floors in the owning moduleiOS zoomed on every field for two versions
No physical properties, longhands includedAlert colours on the wrong edge under RTL
A page must load the stylesheet whose classes it usesTwo interactive tools were inert for months
Scroll containers carry tabindexTables no keyboard could scroll
Class declared in one module onlyHalf-styled components on partial loads

Alongside it: an HTML validator run over every page, a jsdom suite for the optional script, and a browser suite that checks layout, contrast on every palette, touch targets and screenshot baselines. Static checks cannot see whether a rule actually won — only a rendering engine can.

Changing a rule

  1. Find the module that owns the class

    The reference lists it. Editing the wrong file produces a duplicate declaration and the build stops.

  2. Read roles, never palette values

    If no role fits, the honest change is a new role, not a palette read.

  3. Keep it logical

    Inline and block, start and end. No left, right, width or height.

  4. Run the build

    python3 tools/release.py rebuilds, restamps and refuses to finish if any check fails.

  5. Run the browser suite

    npx playwright test. If you changed anything visual, regenerate the screenshot baselines and look at them before committing.