Every trope of the 1990s web, encapsulated as a standards-compliant Custom Element.
32 components for decorating a page, and a site layer that turns them into a whole website — themes, a shared masthead, navigation that knows what page you are on, and a scaffolder that writes the site for you.
No dependencies, no build step, no framework. Each component is a single self-registering file that uses Shadow DOM, so nothing leaks into your page and your page's CSS can't accidentally un-1997 it.
<script type="module" src="components/index.js"></script>
<retro-marquee speed="8">Welcome to my awesome homepage!!!</retro-marquee>
<hit-counter digits="6" label="visitors since 1997"></hit-counter>
<under-construction></under-construction>Or take only what you need — the files have no shared imports:
<script type="module" src="components/retro-counter.js"></script>One command writes a complete, standalone website — pages, navigation, theme and a copy of the library:
npx 90s-new-site my-site --title "Dave's Homepage" --theme geocities
cd my-site && python3 -m http.server 8000See the example site → · five pages, six themes, built from the tags below.
Every page is ordinary HTML with a shell around it. The masthead, navigation and footer read from one config file, so a page added there appears everywhere.
<script type="module" src="site.js"></script>
<retro-page theme="geocities" layout="sidebar-left">
<retro-header slot="header"></retro-header>
<site-nav slot="nav" align="center"></site-nav>
<retro-sidebar slot="sidebar" title="About this site">
<hit-counter digits="6" per-session></hit-counter>
<theme-picker></theme-picker>
</retro-sidebar>
<h2>Welcome to my home page!</h2>
<p>Ordinary HTML goes here, themed to match.</p>
<retro-footer slot="footer"></retro-footer>
</retro-page>// site.js — the whole site, declared once
import { defineSite } from './site/index.js';
defineSite({
title: "Dave's Homepage",
tagline: 'Cats, MIDI files, and the information superhighway',
email: 'dave@example.com',
pages: [
{ href: 'index.html', label: 'Home' },
{ href: 'links.html', label: 'Cool Links' },
],
badges: ['netscape', 'notepad', 'html'],
webring: { name: 'The Personal Homepage Ring', prev: '#', next: '#' },
});A theme is a set of CSS custom properties plus a background, applied to :root.
Because custom properties cross shadow boundaries, one attribute restyles the whole
page — components included.
| Theme | Looks like |
|---|---|
geocities |
Navy starfield, gold headings, yellow marquee |
angelfire |
Pastel pink hearts, Comic Sans, cream panels |
win95 |
Teal desktop, grey chrome, MS Sans Serif |
hacker |
Black, green monospace, matrix rain behind everything |
vaporwave |
Purple grid, hot pink and cyan |
netscape |
Grey and white, Times, blue underlined links |
The same page and the same markup, in three of the six themes.
Drop in a <theme-picker> and visitors can switch between them; the choice is
remembered across pages.
| Tag | What it does | Key attributes |
|---|---|---|
<retro-page> |
The shell: theme, background, centred column, optional sidebar | theme layout width background sidebar-width no-frame |
<retro-header> |
Masthead — WordArt title, tagline, marquee | title tagline marquee preset shape no-marquee |
<retro-footer> |
Badge shelf, counter, globe, e-mail, webring, last-updated | email badges counter-key updated no-counter no-ring |
<site-nav> |
The site's navigation, marking the current page | title align vertical exclude |
<retro-sidebar> |
The narrow column of boxes down one side | title sticky |
<retro-panel> |
A titled content box | title icon variant align |
<theme-picker> |
Switches the theme, remembered in localStorage | label target storage-key no-remember |
<photo-gallery> |
Thumbnail grid with a click-to-enlarge viewer | columns thumb no-captions no-viewer |
<link-list> |
The "cool links" page, with NEW! flags on recent entries | bullet columns new-for-days no-numbers |
<whats-new> |
The dated update log, sorted newest first | title max new-for-days no-sort |
node bin/new-site.mjs <directory> [options]
--title Site title, shown as WordArt in the masthead
--tagline One line under the title
--theme geocities | angelfire | win95 | hacker | vaporwave | netscape
--pages Comma separated slugs (default: home,about,links,gallery,guestbook)
--email Address for the footer's e-mail link
--webmaster Credited in the "last updated" line
--marquee Scrolling text in the masthead
--force Write into a directory that already exists
Slugs named home, about, links, gallery, guestbook and contact get starter
content suited to each; anything else gets a blank page. The result is static files —
publish them anywhere that serves a directory.
| Tag | What it was | Key attributes |
|---|---|---|
<retro-marquee> |
Scrolling text, via CSS animation rather than the tag Netscape gave us | speed direction behavior bgcolor color pause-on-hover |
<blink-text> |
The <blink> tag, restored to active duty |
speed duty color |
<hit-counter> |
"You are visitor number 000042" — real counts in localStorage, odometer roll | key start digits theme label per-session static |
<under-construction> |
Animated barrier stripes and a digging workman | text sub worker no-stripes no-blink |
<retro-hr> |
The animated rainbow divider bar | variant height animated |
<last-updated> |
"This page last updated on…" with a blinking NEW! flag | date prefix by new-for-days |
| Tag | What it was | Key attributes |
|---|---|---|
<word-art> |
Extruded, arched, chrome-plated headline art | preset shape size depth |
<rainbow-text> |
A gradient per line, or a hue per letter | mode speed direction bold |
<fire-text> |
Flaming headline text, no GIF89a required | size speed cool |
<dancing-banana> |
Peanut butter jelly time | emoji count size speed style-name |
<spinning-globe> |
The rotating wireframe Earth beside the email link | size color speed glow |
<email-me> |
The flapping envelope mailto link | email subject label size |
| Tag | What it was | Key attributes |
|---|---|---|
<retro-window> |
Grey chrome, ridged borders, a title bar you can drag | title icon width draggable-window sunken no-buttons |
<popup-alert> |
The modal dialog box — without freezing the browser | title message icon ok-text cancel-text backdrop |
<retro-nav> |
The beveled grey button bar; slot in plain <a> elements |
title align vertical |
<retro-progress> |
"Loading… please wait" in chunky segments | value duration loop segments label done-text |
<retro-clock> |
The seven-segment JavaScript clock | format seconds show-date color size |
<retro-frames> |
The <frameset> layout, with a draggable divider |
nav-width side height |
<no-right-click> |
Image "protection". It never worked. It still doesn't. | message target no-select no-drag |
| Tag | What it was | Key attributes |
|---|---|---|
<tiled-background> |
The seamless repeating tile, drawn on a canvas | pattern size color bg opacity contained |
<starry-night> |
Twinkling star field, optionally with shooting stars | density color speed shooting contained |
<matrix-rain> |
Falling green glyphs, arriving in 1999 | color speed font-size glyphs contained |
<sparkle-cursor> |
The mouse trail script pasted into a million pages | colors size life rate gravity emoji |
| Tag | What it was | Key attributes |
|---|---|---|
<retro-guestbook> |
"PLEASE SIGN MY GUESTBOOK!!", stored in localStorage | key title max placeholder no-form |
<web-ring> |
Prev / Random / Next through somebody's ring | name owner prev next random hub index total list |
<retro-badge> |
The 88x31 button — ten presets, or roll your own | preset label sub colors text-color href blink |
<site-award> |
"Cool Site of the Day", awarded by someone's cousin | title from date stars href ribbon-color |
<retro-banner> |
The 468x60 banner ad, sweeping gloss included | text sub cta href colors flash |
<office-helper> |
The animated assistant with opinions about your letter | messages interval name position |
<status-scroller> |
The message that crawled along the status bar | text messages mode speed position inline |
| Tag | What it was | Key attributes |
|---|---|---|
<midi-jukebox> |
The auto-playing background MIDI, rebuilt in Web Audio | tune tempo volume wave loop |
<dial-up-modem> |
Dial tone, DTMF, ring, carrier, CONNECT 33600 |
number speed volume no-sound |
Nothing is downloaded. No GIFs, no MIDI files, no WAVs, no fonts. Tiles are drawn on a canvas and inlined as data URIs; the flames, globes and star fields are CSS and canvas; the music and the modem handshake are synthesised with Web Audio. The whole library is smaller than one of the animated GIFs it replaces.
Reduced motion is respected. Every animation is wrapped in
@media (prefers-reduced-motion: reduce), and the canvas components check matchMedia
before starting a loop. The nostalgia stops moving for anyone who asks it to.
Sound needs a gesture. <midi-jukebox> and <dial-up-modem> both build their audio
graph on click, because browsers no longer let a page start screeching on load. This is the
one improvement on 1997 that nobody argues with.
Guestbook entries are text, not HTML. Every visitor-supplied string is inserted with
textContent or as a text node, and URLs are checked for an http(s): scheme before being
turned into links.
Timers and listeners are cleaned up in disconnectedCallback, so removing an element
from the DOM stops its animation frames, intervals and document-level listeners.
The site layer shares two modules. Every component in components/ is standalone,
but site/ is not: the theme and the site config are shared state by definition — a
site is more than one page. site/retro-theme.js and site/site-config.js are the
only two modules anything imports.
One file is named oddly on purpose. <hit-counter> lives in components/retro-counter.js,
because ad blockers block any request for a file called hit-counter.js on sight — which,
in fairness, is the most authentically 1990s failure mode this library has.
Styling hooks. Components expose part names (::part(counter), ::part(badge),
::part(window) and so on) plus a handful of CSS custom properties, so you can restyle them
from outside the shadow root without forking the file.
A few components do more than sit there:
// Hit counter driven by a real server-side number
document.querySelector('hit-counter').set(1_048_576);
// Modal dialog that actually returns something
const confirmed = await document.querySelector('popup-alert').show();
// Guestbook, programmatically
book.sign({ name: 'Dave', message: 'Cool page!!!', url: 'http://example.com' });
book.addEventListener('guestbook-signed', (e) => console.log(e.detail));
// The window chrome's close button is cancellable
win.addEventListener('window-close', (e) => e.preventDefault());Anything with Custom Elements v1, Shadow DOM v1 and ES modules — every current version of Chrome, Edge, Firefox and Safari. Notably not Netscape Navigator 3.0.
ES modules need to be served over HTTP rather than opened from the filesystem:
python3 -m http.server 8000
# then open http://localhost:8000/MIT. See LICENSE.








