Red Dot Tracker
facet
facet is a shared design language for a family of websites: design tokens, components and layouts in plain CSS and a little vanilla JavaScript, with no build step. Each site picks its own accent; everything else is the same.
What is here
- TokensNeutral ramp, spacing, radii, shadows and motion.
- ColorThirteen accents, near-neutrals and status colors.
- TypeSource Serif 4, Geist and Geist Mono, with Chinese.
- ComponentsNav, buttons, chips, inputs, alerts and cards.
- LayoutsFifteen page layouts, from reading pages to decks.
- CastDuna, Dudu and Nangnang, the three mascots.
- SEOMaking facet sites easy to find and good to share.
- MockupsDesign explorations: type, color, shape, glass and layout.
Install: from a CDN
Link the stylesheet and the script. Link facet.css first and
cast/cast-layered.css after it (only needed if you use the mascots). Social
icons need icons.css too. Pin a tag so the site does not move with the main
branch.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/h1994st/facet@v0.1.3/dist/facet.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/h1994st/facet@v0.1.3/dist/cast/cast-layered.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/h1994st/facet@v0.1.3/dist/icons.css" /> <script type="module" src="https://cdn.jsdelivr.net/gh/h1994st/facet@v0.1.3/dist/js/facet.js"></script>
jsDelivr can be unreliable in mainland China, and the fonts load relative to
facet.css. For sites aimed there, use the submodule below and serve the files
with the site.
Install: as a git submodule
git submodule add https://github.com/h1994st/facet vendor/facet git -C vendor/facet checkout v0.1.3
Serve vendor/facet/dist/ as static files with your site, then link it by a
relative path.
<link rel="stylesheet" href="/vendor/facet/dist/facet.css" /> <link rel="stylesheet" href="/vendor/facet/dist/cast/cast-layered.css" /> <link rel="stylesheet" href="/vendor/facet/dist/icons.css" /> <script type="module" src="/vendor/facet/dist/js/facet.js"></script>
A minimal page
<html lang="en" data-accent="moss">
<head>
<link rel="stylesheet" href="…/dist/facet.css" />
<script type="module" src="…/dist/js/facet.js"></script>
</head>
<body>
<nav class="f-nav">
<span class="f-nav__brand">Duna Feline</span>
<a href="/" aria-current="page">Blog</a>
</nav>
<main class="f-page f-prose">
<h1>Hairball</h1>
<p>Why the red dot always moves.</p>
</main>
</body>
</html>
-
data-accentpicks the accent (thirteen, on the Color page);data-themeisauto(the default, which follows the system),darkorlight. The theme toggle remembers its choice inlocalStorage["facet-theme"]. Both work on<html>only (:root[data-accent]); to give one part of a page its own accent, set--f-accentand--f-accent-contraston it. The deep shade of the hero card (.f-hero) follows;--f-accent-soft(the tinted backgrounds) is computed on:rootand does not, so set it on that part too, tocolor-mix(in oklch, var(--f-accent) 16%, transparent). -
A plain
.f-navsits in the page flow. Add.f-nav--fixedto float it over the page: while one is present,--f-nav-clearreserves room for it at the top of the layouts. Override--f-nav-clearif your nav is taller. -
Everything sits in
@layer facet.*, so your own unlayered CSS wins without!important, with two exceptions:[hidden]isdisplay: none !important, and when a visitor asks for reduced motion one rule stops every animation and transition on the page, your own included. A layered!importantbeats unlayered CSS, even unlayered!important, so neither can be overridden. -
Any element can be Chinese: set
lang="zh-Hans"on it or on the page, and letter spacing, line breaking and line height adjust.
Behavior
facet's JavaScript is optional. Importing dist/js/facet.js initializes
itself: once the page has parsed, each module takes over the elements that opt in with a
data-f-* attribute. Calling init() again is safe, since elements
already set up are skipped, and a module that fails is reported in the console without
stopping the others.
| Hook | Module | What it does |
|---|---|---|
data-f-ga4 (on <html>), data-f-event,
data-f-label
|
analytics.js |
Optional Google Analytics 4: <html data-f-ga4="G-…"> loads gtag
once and sends one config (one page view). Nothing loads when the
browser sends Global Privacy Control. data-f-event="name" sends that
event on click (with link_url for links and
data-f-label as label); theme switches send
theme_change. A consent banner is out of scope. For GA4 consent mode,
define window.dataLayer and gtag and push
gtag("consent", "default", …) in a classic
<head> script before facet.js:
analytics.js reuses an existing gtag. Do not also keep
your own gtag("config", …) snippet, or every view is counted twice.
|
data-f-theme-toggle |
theme.js |
On a button: cycles dark, light and auto, saves the choice in
localStorage["facet-theme"] and labels the button.
|
data-f-liquid |
liquid.js |
On a .f-card or a container of cards: a light that follows the mouse
across each card. Mouse only.
|
data-f-scrollspy |
scrollspy.js |
On a .f-toc: marks the link of the section being read with
aria-current="location".
|
data-f-nav-cover="#cover" |
nav.js |
On a .f-nav--over: keeps the nav clear over the cover and turns it to
glass once the cover has scrolled away.
|
data-f-autoplay, data-f-autoplay-toggle |
autoplay.js |
On a muted looping video: plays it while it is on screen. A button with
data-f-autoplay-toggle and aria-controls becomes its pause
control (labels from data-f-pause and data-f-play).
|
data-f-story |
story.js |
On a .f-story: pins the stage and shows the image
(img[data-step]) of the step being read.
|
data-f-panels, data-f-panel-toggle |
panels.js |
On a .f-panels: marks the settled panel, plays only its video and fills
an empty nav.f-dots. A panel's data-f-panel-toggle button
becomes its video's pause control.
|
data-f-deck, data-f-piece, data-f-slide,
data-f-from, data-f-deck-prev,
data-f-deck-next
|
deck.js |
On a .f-deck: turns the slides into one stage whose pieces move between
slides (the data format is below).
|
data-f-type |
typewriter.js |
A JSON array of phrases: types each one, holds it, clears it and types the next,
looping. The element's own text is the still state, kept under reduced motion. Tune
with
data-f-type-speed and data-f-type-hold (ms). Put it in an
aria-hidden wrapper next to the real value in visually hidden text.
|
data-f-variants, data-f-variant |
variants.js |
On a .f-segmented with data-variant buttons: switches
every data-f-variant="group:value" inside the control's parent. The
control and the non-default variants ship hidden, so without JavaScript
the first variant shows and there are no dead buttons. A variant whose value has no
button stays hidden, a control with no buttons is skipped and stays hidden, and
variants added after init are not picked up (call init again).
|
Use one module
Each module can be imported on its own. It exports init(root = document),
which returns a cleanup function that removes the listeners and elements it added.
facet.js exports init too, plus cleanup, which
undoes its own initialization.
import { init } from "…/dist/js/deck.js";
const stop = init(document.querySelector("#talk"));
// Later, before the deck leaves the page:
stop();
Avoid a theme flash
facet.js runs after first paint, so a saved theme would arrive late. Put this
line in <head>, before the stylesheet: it applies
localStorage["facet-theme"] (only dark, light or
auto) before the page paints, and the toggle then just shows it.
<script>try{var t=localStorage.getItem("facet-theme");if(/^(dark|light|auto)$/.test(t))document.documentElement.dataset.theme=t}catch(e){}</script>
Deck data
Each .f-deck__slide's data-f-slide is JSON that lists the state
of every piece (by its data-f-piece name) the slide shows; a piece left out
is hidden (a beam retracts to its start, so it grows again when it comes back). Positions
and sizes are percentages of the board.
| Piece | State |
|---|---|
Tile .f-deck__tile |
{"at": [left, top], "c": color, "lit": true}: lit fills it
with its color.
|
Beam .f-deck__beam |
{"beam": [left, top, length]}: .f-deck__beam--v runs
vertically.
|
Box .f-deck__shot |
{"box": [left, top, width, height], "fade": true}:
fade fades it out.
|
A piece starts where it first appears, or at its data-f-from ([left, top]
or a box). Bad data (not JSON, or a field without enough numbers) leaves the deck in its
no-JS form, with a console warning that names the slide.
<section
class="f-deck__slide"
data-f-slide='{"laser": {"at": [20, 50], "c": "var(--f-deck-ink)"},
"b1": {"beam": [20, 50, 60]},
"target": {"at": [80, 50], "c": "var(--f-accent)", "lit": true}}'
>
<h2>In straight lines.</h2>
</section>
Reduced motion and no JavaScript
- Without JavaScript, content stays readable and links work: a story shows each step's image inline, panels and decks become plain stacked sections, footnotes open from their number, and the nav over a cover is plain glass.
-
Under
prefers-reduced-motion: reduce, animations and transitions stop (on the whole page, as noted above), videos stay paused on their posters with no pause control, panels stop snapping, and moving between slides or panels jumps instead of scrolling smoothly.