A Win9x-style CSS framework that brings the mid-90s desktop look to modern web applications.
- Authentic Win9x chrome — raised and sunken bevels, square corners, and a grey chassis, down to the light source sitting at the top-left.
- Dark mode that follows the OS. The dark palette ships under
prefers-color-schemeas well as[data-theme], so a dark-OS visitor is painted dark on the first frame with no flash. Using the toggle stores an explicit choice, which then wins. - WCAG AA in both themes, enforced. All 370 text/surface pairs the framework produces are checked on every push, as is every rendered page.
- Keyboard-operable. Every control the JavaScript drives is in the tab order with an accessible name — carousel dots, rating stars, tag removes, sortable headers — and tooltips appear on focus, not only on hover.
- Right-to-left. Set
dir="rtl"and the layout mirrors. The bevels deliberately do not, because Windows does not mirror them either. - One token rounds everything.
--retro-border-radiusis0by default; set it once and the whole framework follows. - Ships ESM, CJS and TypeScript types behind an
exportsmap, and is safe to import during server-side rendering. - Data attribute API for modals, toasts, tooltips and dropdowns — no JavaScript required to use them.
- No runtime dependencies. ~21 KB of CSS and ~8 KB of JavaScript, gzipped, and the JavaScript is optional.
npm install @phantompixeldev/retrocss<!-- CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@phantompixeldev/retrocss/dist/retro.min.css">
<!-- JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/@phantompixeldev/retrocss/dist/retro.min.js"></script>The package ships ESM, CJS and a browser IIFE behind an exports map, so a
bare import resolves to JavaScript and the stylesheet has its own entry point.
// JavaScript
import RetroCSS from '@phantompixeldev/retrocss'; // ESM
const RetroCSS = require('@phantompixeldev/retrocss'); // CJS
// Styles — pick one
import '@phantompixeldev/retrocss/css'; // full
import '@phantompixeldev/retrocss/css/min'; // minifiedImporting the bundle is safe during server-side rendering: it touches no browser global until the DOM exists, so Next, Remix and Astro can import it at the top level.
@use '@phantompixeldev/retrocss/scss' as retro;Type definitions ship in the package and are picked up automatically — there is
no @types package to install.
import RetroCSS, { RetroTheme, RetroHue } from '@phantompixeldev/retrocss';
const theme: RetroTheme = 'dark'; // 'light' | 'dark'
const hue: RetroHue = 'success'; // every hue with a full token set
RetroCSS.applyTheme(theme);
RetroCSS.toast.show('Saved', { type: hue, duration: 0 });Download the latest release from GitHub and include the CSS and JS files in your project:
<link rel="stylesheet" href="path/to/retro.min.css">
<script src="path/to/retro.min.js"></script>RetroCSS provides a wide range of components and utilities to build retro-styled interfaces:
<div class="retro-card">
<div class="retro-card-header">System Properties</div>
<div class="retro-card-content">
<p>Welcome to RetroCSS!</p>
<button class="retro-btn retro-btn-primary">OK</button>
</div>
</div>See the documentation for detailed usage instructions and examples.
Complete pages built from the framework and nothing else — no per-page component CSS. Every one is checked in CI at five widths in both themes.
| Example | What it shows |
|---|---|
| Dashboard | Application shell: sidebar, stat tiles, activity feed |
| Blog | Post listing, featured item, category badges |
| Blog post | Long-form prose, pull quote, code block, comments |
| Sign in | Compact auth form with a show-password toggle |
| Sign up | Longer form with live password requirements |
| Right-to-left | The same components under dir="rtl" |
| Theme matrix | Every hue on every surface, both themes |
RetroCSS uses a modular JavaScript architecture with ES modules. All interactive components can be triggered via JavaScript or data attributes:
// Import all components (bundled version)
import RetroCSS from '@phantompixeldev/retrocss';
// Initialize all components
RetroCSS.init();
// Use individual components
RetroCSS.modal.show('myModal');
RetroCSS.toast.show('Hello World', { type: 'success' });Data Attribute API Example:
<!-- Show a toast on click -->
<button data-retro-toast="Hello from RetroCSS!">Show Toast</button>
<!-- Show a modal on click -->
<button data-retro-modal="myModal">Open Modal</button>Show toast notifications with rich HTML content:
<button
data-retro-toast="<b>Custom Toast</b><br>With <i>HTML</i> content!"
data-retro-toast-html>
Show Custom Toast
</button>Or via JavaScript:
RetroCSS.toast.show('<b>Custom Toast</b><br>With <i>HTML</i> content!', { html: true });Note: Only use trusted HTML for toasts. HTML toasts get a
.retro-toast-htmlclass for custom styling.
RetroCSS is customized through CSS variables, organised in four tiers per colour. Picking the right tier matters: a value tuned as a background is usually unreadable as text.
| Token | Role |
|---|---|
--retro-primary |
Fill — background of a filled badge, button or alert |
--retro-primary-fg |
On-fill — text placed on that fill |
--retro-primary-text |
On-surface — that hue used as text on a page background |
--retro-primary-hover / -active |
States — fill under :hover / :active |
All four exist for every hue (primary, success, danger, warning, info, teal, tan, pink, lime, cyan, orange, brown, violet, gray, maroon, gold, navy, olive, silver).
Every pair clears WCAG AA (4.5:1) in both themes — npm run check:a11y verifies it.
:root {
--retro-primary: #0000aa; /* fill */
--retro-primary-fg: #ffffff; /* text on that fill */
--retro-primary-text: #000080; /* that hue as text on a page background */
--retro-body-bg: #c0c0c0; /* page */
--retro-bg: #ffffff; /* raised surface */
--retro-text: #000000;
--retro-text-muted: #4b4b4b;
}
/* Dark mode */
[data-theme="dark"] {
--retro-body-bg: #181818;
--retro-primary: #4a90e2;
--retro-primary-fg: #000000; /* dark fills are light, so black sits on top */
--retro-primary-text: #4b91e2;
}Typography is tokenised the same way: --retro-font, --retro-font-heading,
--retro-font-mono, a rem-based --retro-font-size-xs … -3xl scale,
--retro-line-height and --retro-font-weight-*. Headings use the body stack by
default; for a modern heading font, set
--retro-font-heading: 'Segoe UI', Tahoma, sans-serif;.
Corners are square, because Windows 95 was. Every component routes its corners through one token, so a single declaration rounds the whole framework — buttons, cards, badges, inputs, navs and the rest together:
:root { --retro-border-radius: 6px; }.retro-rounded / -lg / -full still round one element at a time. Three
things keep their shape on purpose: .retro-nav-pills and .retro-tag, which
are named for it, and .retro-badge-pixel, whose whole point is being square.
Radio buttons and the spinner are circles, as they are in Windows. Tables stay
square because border-collapse: collapse — what merges their cell borders
into a single hairline — makes every engine ignore border-radius.
Set dir="rtl" and the layout mirrors. Spacing, text alignment and start/end
positioning use logical properties, so there is nothing to import and no
separate stylesheet.
<html lang="ar" dir="rtl">The bevels deliberately do not mirror. In the Win9x visual language the light source is fixed at the top-left, and Windows keeps it there in RTL; flipping the raised and sunken edges would make every button read as sunken on the wrong side. Only the semantic accents move — an alert's stripe, a blockquote's rule — because those mark where a line of text begins. See the RTL example.
Dark mode follows the operating system when the visitor has expressed no
preference of their own. The moment they use a .retro-theme-toggle, that
choice is stored and outranks the OS from then on.
This is done in CSS, not script — the dark palette is emitted both for
[data-theme="dark"] and under @media (prefers-color-scheme: dark) for
:root:not([data-theme="light"]) — so a dark-OS visitor gets the right colours
on the very first paint, with no flash, even before the bundle loads.
The one case CSS cannot see is a stored choice that differs from the OS.
Add this to your <head> to cover it. It must be inline: an external or
deferred script runs after the first paint, which is the whole problem.
<script>
try {
var retroTheme = localStorage.getItem('retro-theme');
if (retroTheme) document.documentElement.setAttribute('data-theme', retroTheme);
} catch (e) {}
</script>Upgrading? See MIGRATION.md. No class has ever been renamed. 6.0 changes three visual defaults and no API:
--retro-dangeris#cc0000with white text on it (it was#ff0000with black), the progress label sits on a plate so it survives a saturated bar, and the sidebar is drawn as a raised panel with a title bar. Each one is a one-line revert. 4.0 moves the package's main entry from a CSS file to JavaScript and adds anexportsmap — every existing deep path still resolves — and--retro-border-radiusnow reaches the core chrome, so if you had already set it, more will round than before. 3.0 raises the body text to 16px and drops the!importantfrom theborder-radiusreset, both visible on every page, each with a one-line override. On 3.0.0, upgrade to 3.0.1: dropping that!importantwoke 31 dormant radius declarations and rounded eleven components. 2.0 restyled a few things to meet WCAG AA.
Atomic helpers driven by the same tokens as the components, all retro- prefixed.
| Group | Classes |
|---|---|
| Layout | retro-main-layout, retro-flex, retro-flex-col, retro-flex-1, retro-items-center, retro-justify-between |
| Spacing | retro-m{t,b,l,r,x,y}-0…8, retro-p…, retro-gap-0…8, retro-mx-auto (0, 4, 8, 12, 16, 24, 32, 40, 48px) |
| Sizing | retro-w-full, retro-max-w-full, retro-max-w-prose, retro-max-w-{sm…xxl} |
| Colour | retro-bg-<hue>, retro-bg-<hue>-subtle, retro-text-<hue>, retro-border-<hue> |
| Effects | retro-raised, retro-sunken, retro-shadow-{sm,md,lg}, retro-rounded |
retro-bg-<hue> sets the fill and its matching on-fill text colour, so a filled
panel is legible in both themes from a single class:
<div class="retro-bg-primary retro-p-4">Readable in both themes</div>
<div class="retro-bg-success-subtle retro-p-4">Tinted callout</div>Pick the tier by role: retro-bg-* to fill a surface, retro-text-* for text sitting
on a page surface. Putting an on-surface colour inside a filled panel paints the hue
on itself — that is the one combination to avoid.
Every text/surface pair the framework produces clears WCAG AA (4.5:1) in both
themes. npm run check:a11y compiles the SCSS and asserts it, and fails the
build on a regression — it also catches any var(--retro-*) that resolves to
nothing.
That proves the tokens are sound. npm run check:pages proves the pages
are: it renders every page in the repo in real Chromium, in both themes, at
1200/980/760/420/360px, and fails on a console error, horizontal overflow, a
missing or duplicated <h1>, any rendered text below AA against the surface
actually painted behind it, or an input glyph off its field's centre line.
Neither can see whether a control can be reached, so npm run check:keyboard
asserts that too: every control the framework drives from script is in the tab
order and carries an accessible name, no form control ships without one,
roving-tabindex groups expose exactly one tab stop, and every tooltip appears on
focus and not only on hover. npm run check:css gates the stylesheet itself,
and npm test is a jsdom unit suite. All five run in CI on every push.
Beyond colour:
- Focus rings are
:focus-visible. Keyboard and assistive-tech users get a ring; mouse clicks do not. Text inputs still show one on click, because browsers treat their focus as visible. - Modals are keyboard-safe. Opening one sets
role="dialog",aria-modal, and names it from its header; focus moves inside and is trapped there, the rest of the page is madeinert, and closing returns focus to whatever opened it. - Dropdowns are navigable.
aria-haspopup/aria-expandedon the toggle,role="menu"/menuitemon the menu, and Arrow / Home / End / Escape / Tab all behave. Escape returns focus to the toggle. prefers-reduced-motionis honoured — every animation and transition is neutralised, with the looping text effects switched off outright.- Sortable tables are operable.
.retro-table-sortableheaders are focusable, sort on Enter and Space as well as click, and carryaria-sort. Adddata-sort="none"to a column that should not sort. - Rating stars are a radiogroup. Tab reaches the group, Arrow keys move and
set the value, Home/End jump to the ends, and each star reports
aria-checked. - Carousels are keyboard-driven. Dots are real buttons with labels and
aria-current; Left/Right arrows move between slides once focus is inside. - Tooltips appear on focus, not only on hover, are dismissible with Escape,
and are wired to their trigger with
aria-describedby(WCAG 1.4.13). - Tabs use a roving tabindex. Tab steps over the tablist into the panel; Arrow keys, Home and End move between tabs.
- Dismiss controls meet the 24px target WCAG 2.5.8 asks for. Inline text
links are exempt, and the
-xs/-smbutton variants are opt-in. .retro-sr-onlylabels icon-only controls;.retro-sr-only-focusablegives you a skip link. Use it — not.retro-hidden— to hide a real form control you still want reachable, such as a styled<input type="file">:display: nonetakes it out of the tab order entirely.
<button class="retro-btn">💾<span class="retro-sr-only">Save</span></button>RetroCSS supports all modern browsers:
- Chrome/Edge (latest)
- Firefox (latest)
- Safari (latest)
- Opera (latest)
Clone the repository:
git clone https://github.com/phantompixeldev/retrocss.git
cd retrocss
npm installBuild the project:
npm run buildWatch for changes:
npm run watchRun the gates:
npm test && npm run check:a11y && npm run check:css && npm run check:pages && npm run check:keyboardnpm test is the fast loop — a jsdom unit suite over the built bundle.
check:a11y gates the tokens (every var(--retro-*) resolves, every
text/surface pair clears WCAG AA), check:css gates the stylesheet statically
(nothing hardcodes a corner behind --retro-border-radius, nothing builds a
shadow or scrim from a token that inverts between themes, and no component
class ships without appearing on a page), check:pages gates the
rendered result across 10 pages × 2 themes × 5 widths — including that the first
paint is already the right theme with the bundle blocked, so a theme flash fails
the build — and check:keyboard gates operability: that every control the
framework drives can be reached and named, that roving-tabindex groups expose
exactly one tab stop, and that no form control ships without an accessible
name.
check:pages and check:keyboard need a browser once: npx playwright install chromium.
The SCSS carries SassDoc /// comments throughout. The sassdoc renderer
was dropped in 5.0: unmaintained since 2022, its output was never published,
and it accounted for every security advisory in the dependency tree.
RetroCSS imitates the visual style of mid-1990s desktop software. It is an independent project, not affiliated with, sponsored by, or endorsed by Microsoft. Windows is a trademark of Microsoft Corporation, named in this documentation only to describe the look the framework reproduces. No Microsoft artwork, icon, font or code is included.
MIT