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.
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.
| Module | Source | Minified | Min + gzip | Required |
|---|---|---|---|---|
| Core · min | 52.5 KB | 20.7 KB | 5.3 KB | Always |
| Headers · min | 25.5 KB | 10.4 KB | 2.6 KB | With a nav or hero |
| Content · min | 45.0 KB | 19.5 KB | 4.3 KB | Page body components |
| Forms · min | 15.7 KB | 5.8 KB | 1.6 KB | Beyond the four basic controls |
| Components · min | 47.3 KB | 20.8 KB | 4.2 KB | App UI: badges, tabs, menus, lists |
| Footers · min | 9.9 KB | 4.6 KB | 1.2 KB | With a footer |
| Space · min | 8.3 KB | 2.5 KB | 0.6 KB | Padding, margin and gap utilities |
| Effects · min | 18.8 KB | 9.4 KB | 1.8 KB | Frost, gradients, animations |
| Utilities · min | 20.3 KB | 9.0 KB | 2.7 KB | Optional helpers |
| Variants · min | 4.2 KB | 1.8 KB | 0.6 KB | Curated hover, focus and active states |
| Classless · min | 9.1 KB | 6.1 KB | 1.5 KB | Styles bare semantic HTML |
| Fonts · min | 8.0 KB | 3.3 KB | 0.6 KB | Self-hosted @font-face |
| polyfish.js · min | 24.4 KB | 9.9 KB | 3.7 KB | Optional enhancements |
| Bundle · min | 244.0 KB | 102.1 KB | 18.7 KB | The 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.
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.
<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.
<!-- 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.
<!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.
<!-- 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-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 loaded | Separate files, gzipped | Requests |
|---|---|---|
| 4 — Core, Headers, Content, Footers | 13.4 KB | 4 |
| 6 — plus Forms and Components | 19.2 KB | 6 |
| 7 — plus Space | 19.8 KB | 7 |
| 8 — plus Effects | 21.6 KB | 8 |
| All nine core modules, as the bundle | 18.7 KB | 1 |
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.
<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:
@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.
<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.
<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.
| Feature | Bootstrap | Tailwind | Pico | Bulma | PolyFish |
|---|---|---|---|---|---|
| Components | Yes | No (add a library) | Minimal | Yes | Yes |
| Utilities with state variants | Some, no hover | Yes, via compiler | No | Some | Yes, no compiler |
| Classless mode | No | No | That is the product | No | Optional module |
| Modals and menus | JavaScript | Bring your own | JavaScript for modals | JavaScript | Native popover and invoker commands |
| Build step | Optional (Sass) | Required | None | Optional (Sass) | None |
| Load only what you use | Via Sass | Purge at build | One file | Via Sass | Twelve files, link what you need |
| Your CSS wins without !important | No | Partly | No | No | Cascade layers |
| Right to left | Separate RTL build | Logical utilities | Partly | Partly | Logical throughout, one file |
| Retint at runtime | Custom properties, partly | Rebuild | Custom properties | Rebuild | Eleven role tokens |
| Works under a strict CSP | Yes | Yes | Yes | Yes | Yes, 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 say | Tailwind | PolyFish |
|---|---|---|
| "The scale keeps us consistent" | Design tokens in a config | The 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 compiler | pf-hover-bg-accent, plain CSS, no compiler. Nine states, twenty-six utilities |
| "Responsive prefixes are great" | md:hidden | pf-md-hidden — same idea, one naming convention across states and breakpoints |
| "The markup is unreadable" | Twenty utilities on a button | pf-btn pf-btn--secondary. Components for things you repeat, utilities for the one-off |
| "I have to memorise abbreviations" | p-4, tracking-tight, shrink-0 | Components 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 variants | None. 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 source | npm 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 components | Components included, and they work without JavaScript |
| "It fights the cascade" | Flattens everything to one specificity | Uses 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
| Tailwind | PolyFish |
|---|---|
flex flex-col gap-4 | pf-stack |
flex flex-wrap gap-2 items-center | pf-cluster |
grid grid-cols-1 md:grid-cols-3 gap-6 | pf-grid pf-grid--2 |
max-w-7xl mx-auto px-4 | pf-container |
mt-4, p-6, gap-3 | pf-mt-4, pf-p-6, pf-gap-3 |
md:hidden | pf-md-hidden |
hover:shadow-lg | pf-hover-shadow-lg |
focus-visible:ring | pf-focus-ring |
group-hover:translate-x-1 | pf-group-slide on the child, pf-group on the parent |
dark:hidden | No 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/2 | pf-w-50 |
truncate | pf-truncate |
sr-only | pf-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.
| Symptom | Cause | Fix |
|---|---|---|
| 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.
<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:
/
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:
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.
| File | Covers | Size | Needs Core |
|---|---|---|---|
| Core | Tokens, reset, type, layout, buttons | 20 KB | Is Core |
| Headers | Top bar, nav, heroes, page headers | 10 KB | Yes |
| Content | Sections, cards, tables, forms, code | 19 KB | Yes |
| Forms | Floating labels, validation, range, file | 6 KB | Yes |
| Components | Badges, tabs, menus, lists, dialogs | 20 KB | Yes |
| Footers | Footer layouts and link columns | 5 KB | Yes |
| Fonts | Self-hosted @font-face declarations | 3 KB | Independent |
| Classless | Styles bare semantic elements, no classes | 5 KB | Independent |
| Space | Padding, margin, gap and width utilities | 2.5 KB | Yes |
| Effects | Frost, gradients, animations, group hover | 9.4 KB | Yes |
| Utilities | Display, sizing, position, visibility helpers | 8.5 KB | Yes |
| Variants | Curated hover, focus, active and group states | 2 KB | Yes |
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.
Buttons, type, layout
Every button variant, the type scale, container, stack, cluster, grid and split.
Core — always onNav, hero, page head
Announcement bar, sticky navigation with the mobile menu, four hero variants, breadcrumbs.
needs HeadersSections, cards, tables
Section rhythm, cards, plans, stats, tables, alerts, code blocks, accordions, sidebars.
needs ContentFloating labels, validation
Input sizes, floating labels, feedback messages, fieldsets, range, file inputs, option cards.
needs FormsTabs, menus, dialogs
Badges, button groups, pagination, progress, lists, avatars, switches, tabs, menus, dialogs, toasts.
needs ComponentsPadding and margin utilities
Top and bottom margins, padding, gaps, proportional widths and flex sizing.
needs SpaceFrost, gradients and animation
The frosted panel, gradient surfaces and text, four animations, scroll reveal, group hover.
needs EffectsHover and focus accents
Curated state classes: hover background, foreground, border, underline, shadow and lift; focus rings; group hover and focus.
needs VariantsBare semantic HTML
Headings, links, buttons, fields, tables, code and quotes styled with no classes at all.
needs ClasslessCheckboxes 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 |
|---|---|---|
| Core | 78 | Tokens 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 |
| Headers | 37 | .pf-topbar, .pf-nav with the checkbox menu, four .pf-hero variants, .pf-pagehead, .pf-breadcrumb |
| Content | 96 | .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 |
| Forms | 37 | Input sizes, .pf-float, .pf-feedback, .pf-fieldset, .pf-range, .pf-file, .pf-choice-card, .pf-form__row, .pf-counter |
| Components | 104 | .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 |
| Footers | 16 | .pf-footer and its column, slim and accent variants, link columns, social row, legal bar, .pf-totop |
| Classless | 0 | Element selectors only — headings, links, buttons, fields, tables, code, quotes, <details>, <dialog>, every rule in :where() at the lowest layer. No classes. |
| Space | 76 | Top and bottom margins, padding, gaps, proportional widths and flex sizing, with responsive gap at two breakpoints |
| Effects | 26 | The frosted panel, gradients, four animations, scroll reveal, .pf-group hover and isolation |
| Utilities | 188 | Display, sizing, position, typography, colour and visibility helpers, container-query helpers, scroll reveal, and the print stylesheet |
| Variants | 20 | Curated 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-* |
| Fonts | 0 | Nine @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-btnwith--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-visiblering, 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--greenvariant.pf-nav— sticky and blurred by default;--staticand--flushvariants.pf-hero—--split,--centerand--photovariants, with the mesh background masked in.pf-pageheadand.pf-breadcrumbfor 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--flatis 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:
| Rule | The failure it came from |
|---|---|
| Components read roles only | Five 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 tokens | Dark text on a dark button inside .pf-invert |
| Touch floors in the owning module | iOS zoomed on every field for two versions |
| No physical properties, longhands included | Alert colours on the wrong edge under RTL |
| A page must load the stylesheet whose classes it uses | Two interactive tools were inert for months |
Scroll containers carry tabindex | Tables no keyboard could scroll |
| Class declared in one module only | Half-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
Find the module that owns the class
The reference lists it. Editing the wrong file produces a duplicate declaration and the build stops.
Read roles, never palette values
If no role fits, the honest change is a new role, not a palette read.
Keep it logical
Inline and block, start and end. No left, right, width or height.
Run the build
python3 tools/release.pyrebuilds, restamps and refuses to finish if any check fails.Run the browser suite
npx playwright test. If you changed anything visual, regenerate the screenshot baselines and look at them before committing.