Skip to content
PolyFish CSS

A framework you can read in an afternoon

PolyFish exists because most pages do not need a build step, and most CSS frameworks make you take one anyway.

What it is

PolyFish is twelve hand-written CSS files covering the parts a page is made of: a required core of tokens and primitives, then headers, content, forms, components, footers, spacing, effects and utilities, plus three optional files for fonts, state variants and classless styling. You link the ones your page has and skip the ones it doesn't. There is no compiler and no configuration file, and nothing needs JavaScript to render.

What it is not

Behaviour is optional and it is separate. Every component renders and functions from CSS alone; an optional 10 KB script upgrades a handful of them — closing a menu on an outside click, giving the tabs a real ARIA tablist, counting characters in a textarea. Nothing depends on it, and nothing is hidden by it.

It is also not a utility-first framework. Utilities exist in the fifth file and they are deliberately small — enough to stop you writing one-off rules, not enough to build a page out of class strings.

Principles

  • Readable source. What you download is what you read. Every file has section headers and comments explaining why a rule exists.
  • Roles, not colours. Components reference --pf-accent, never a hex value. Retheming is eleven custom properties, applied at runtime.
  • Ordinary HTML. One or two classes per element, no required wrapper divs beyond a container.
  • Prefixed everything. Every class starts with pf-, so dropping it into an existing project collides with nothing.

Versioning

PolyFish follows semantic versioning. Within a major version, class names and token names do not change meaning — a value may be tuned, a component may gain a variant, but existing markup keeps rendering the way it did. Removals wait for a major.

Browser support

The framework uses custom properties, clamp(), grid, gap, clip-path and color-mix(). Everything degrades: where color-mix() is unsupported the sticky navigation falls back to a solid background, and where backdrop-filter is unsupported it simply doesn't blur. Anything from roughly 2023 onward renders as designed.

Licence

MIT. Use it commercially, fork it, rename it, ship it inside a product. Attribution is appreciated and not required.

The name

The mark is a betta built out of a hexagon and three fans of triangles, all radiating from a single point. It is the framework's argument in one image: a small number of regular pieces, arranged deliberately, is enough.

Where the constraints came from

Each rule below exists because of a specific failure, not a preference. They are in the order they were learned.

RuleThe failure behind it
Components read roles, never palette valuesRetinting a framework by editing component rules means every update re-opens the same merge conflict. Eleven role properties make a theme a stylesheet instead of a fork.
No element is hidden by JavaScript on loadA script that hides content and then fails to load has deleted that content. The optional layer only upgrades what already works.
Nothing injects CSS from scriptA script that writes styles forces unsafe-inline into your Content-Security-Policy. The two elements the script creates are styled in the Content module instead.
Data values get utility classes, not inline stylesA rating bar needing style="width: 69%" is a framework quietly forcing you to weaken your policy. Twenty-one width classes are cheaper than that.
Every animation sits inside a reduced-motion guardSlowing an animation that starts at opacity: 0 to nothing leaves the element invisible forever. Not applying it is the only correct behaviour.
Grid children carry min-width: 0A wide table stretches its track past 1fr and scrolls the whole page sideways. One line, and it was latent here for four versions.
Form fields are floored at 16px on touchiOS Safari zooms on focus below that and never zooms back out. The fields were at 14px.

Versioning

Pre-1.0: the API can still change in a minor release. Every rename and removal is listed with its replacement in the migration guide. Patch releases never change a class or token name.

How the site verifies itself

Every claim on this site is checked by a script rather than remembered, because the numbers move every release. The build fails on any of these:

  • Structure and SEO — one h1 per page, no duplicate ids, every internal link and fragment resolving, canonical uniqueness, sitemap parity, no orphan pages.
  • Documentation coverage — every public class must appear somewhere on the site. It was 41% before the check existed.
  • Module waste — a page may not link a module it never uses. Seventeen pages were shipping one.
  • Consistency — every site page uses the shared header, the shared footer, the theme toggle and one page head. Eighteen pages once carried thirteen different navigations.
  • Mobile — nineteen checks covering viewport, fixed widths, table wrappers, viewport units, touch targets, iOS zoom, safe areas and menu reachability.
  • Accessibility — heading order, landmarks, an accessible name on every control, link text, skip-link targets.
  • Correctness — W3C validation on every page, source-versus-minified rule parity on every module, and 69 jsdom assertions on the optional script, twelve of which check that the page survives the script's absence.

The build is also deterministic: it runs four times in a row producing byte-identical output, because a non-deterministic build makes a real change indistinguishable from noise.

Who it is built for

PolyFish is the framework behind SudoSecurity, a technical publication covering security tooling and developer releases. That is not a footnote — it is the reason the framework looks and behaves the way it does. Every constraint here came from shipping a real publication on it, and the priorities are the publication's priorities, in its order: security first, performance second, design that works for a first-time reader and a fifteen-year sysadmin third.

Security decided the architecture

A publication is a static site with an editorial workflow behind it, so the framework had to work without a runtime. No script is required to render a page, nothing is injected into the DOM, and no asset comes from a third-party origin — which is what lets the served policy refuse remote scripts, remote styles and remote fonts outright rather than allow-listing a CDN.

Performance had to hold at both ends

An article page loads four files, about 13 KB over the wire. The documentation you are reading loads the bundle at 18 KB. Both are cached for a year with a versioned URL, so a returning reader downloads nothing. The modular split exists because a long-form article has no pricing table and should never pay for one.

Readability is the design brief

Large headers, a body face that holds up at length, a measure capped at 66 characters and vertical rhythm from a token rather than habit. A publication lives or dies on whether a 3,000-word piece is comfortable to read on a phone at midnight, which is a harder test than any landing page.

What a publication needs that a landing page does not

Most CSS frameworks are optimised for the marketing page and treat long-form content as an afterthought — a .prose class bolted on at the end. Publishing on this one surfaced a different list:

NeedWhat it became
Article body that stays readable at lengthA measure token applied to p and li globally, with per-element opt-outs, plus prose rhythm driven by the space scale rather than browser defaults
Code samples in running textA code block with a filename bar, four highlight classes and no tokeniser — highlighting is whatever your pipeline already does
Tables of versions, CVEs and benchmarks on a phoneA required scroll wrapper, a stacking variant that reads cell labels from data-pf-label, sticky headers and captions
Callouts that do not shoutAlerts coloured by a left rule and a wash rather than a filled block, so they can sit mid-article
Hub pages and category indexesCard grids, stat rows and the section rhythm variants, so an index page and an article share one visual language
A theme that survives a redesignEleven role tokens. The publication's palette is a stylesheet, not a fork of the framework

Where it runs

This site and SudoSecurity are both served from SudoSecurity Cloud on nginx, with the configuration published in nginx/ so it can be read rather than described. Static files, immutable caching on versioned URLs, a strict Content-Security-Policy and no interpreter in the request path. The whole framework is eleven CSS files and one optional script; there is nothing to patch on a CVE cadence, which for a security publication is the entire point.

Decisions, and the ones I would make differently

A framework's changelog shows what changed. This is the shorter, more useful list: what was decided, and which of those decisions turned out to be wrong.

DecisionHeld up?What happened
Two-layer tokens Yes The single best decision in the project. Four themes, light mode, five playgrounds and a complete palette change all happened without touching a component rule.
Modules over a monolith Mostly An article page loading 13 KB instead of 18 KB is real. But past six files the bundle wins on both size and requests, which was not obvious until it was measured — so the site ships whichever is smaller.
Naming tokens after colours No --pf-blue-800 held #f4f7fa in the cream theme. The name was correct in exactly one palette and lied in every other. Renamed in 0.16 with a compatibility shim; it should have been positional from the first commit.
Physical properties No 182 margin-left declarations meant the framework quietly did not support right-to-left. Converting them was mechanical; doing it after eighty pages of markup existed was not. Logical from the start would have cost nothing.
One hand-typed version number No It was stamped across thirteen files and a cache-busting query. The first time it was missed, returning visitors kept an old stylesheet for a year while new ones got the current palette — the same page in two colour schemes depending on who looked. Now derived from a content hash.
An iframe to demo responsiveness No It was the most convincing demo on the site, and it cost frame-ancestors 'self' — weakening a header that protects every page, for one page's benefit. Removed in 0.18.3 and replaced with a container-query stage that is honest about being one.
Static checks as the safety net Partly Thirteen groups of checks caught seventeen real bugs, including a redirect loop and a class declared in two modules. They also missed two layout bugs outright, because nothing in them lays out a page. Necessary, not sufficient.

Security posture

The framework itself has no runtime, so its attack surface is the CSS parser and whatever you build on top. The interesting part is what that makes possible for the pages that use it.

  • The policy allow-lists exactly one origin. Styles, fonts and images are entirely first-party, and script-src names one host: analytics.sudosecurity.org, a self-hosted analytics endpoint on infrastructure I run. That is the whole exception list, and it is a named origin rather than a wildcard. The framework itself still loads nothing from anywhere.
  • The analytics script carries no integrity hash, deliberately. The vendor updates the file in place, so a pinned hash would break measurement silently on their next deploy. The mitigations are that the origin is mine, the tag is deferred so it cannot block rendering, and connect-src permits that one host and nothing else — a compromised script could not exfiltrate to anywhere but the server it came from.
  • No unsafe-inline is ever needed. Not for styles — data values like a progress percentage have utility classes. Not for scripts — the optional file is external, deferred, and carries a subresource-integrity hash.
  • Nothing is framed. frame-ancestors 'none' and X-Frame-Options: DENY, with a build check that fails if the markup and the policy ever disagree in either direction.
  • Assets are immutable and content-hashed. A year-long cache is only safe while the URL changes with the bytes, so the URL is derived from the bytes.
  • The server configuration is published in nginx/ and tested by nginx -t in CI, because a security claim you cannot read is a marketing claim.

Maintenance

Pre-1.0: the API can still change in a minor release. Every rename and removal is listed with its replacement in the migration guide. Patch releases never change a class or token name.

Token values are not covered by that promise. If you pin a hex instead of reading the token, a palette change will reach you — which is the trade you accept for a framework that can be retinted in eleven properties.

Security fixes go out as patches against the current major. There is no LTS branch, because one person maintains this and pretending otherwise would be worse than saying so.

Start with one file.

Core is 5 KB and does most of the work. Add the rest as the page needs them.