peels.css
All of Peels + the shipped theme. Standard use — ~140 KB.
One CSS file, one attribute on <html>, semantic HTML. No dependencies.
All of Peels + the shipped theme. Standard use — ~140 KB.
The same + all optional themes (color, type, density) — for a theme switcher.
The <link> loads Peels; data-theme="peels" activates the shipped theme. Without the attribute, the base stays in its bare state (see below). Your CSS lives in a second file: unlayered, it always wins.
<!doctype html>
<html lang="en" data-theme="peels">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="peels.css">
<link rel="stylesheet" href="site.css">
</head>
<body>
<h1>Heading</h1>
<p>Semantic HTML, cleanly rendered.</p>
<button>Action</button>
</body>
</html>
data-theme-mode="dark" or "light" forces the scheme — otherwise light-dark() follows the OS. Works in the bare state too.data-icon="…" — pure-CSS icons, no font.Without data-theme, no rule from the theme layer applies: pure grays, system font. A blank canvas to write your own theme, under your own attribute value.
<html> <!-- bare base -->
<html data-theme="peels"> <!-- shipped theme -->
The --peels-* roles can be set globally or on a context and are inherited: here, cream page background and accent borders everywhere, and only the aside's buttons become bigger pills.
/* site.css */
@layer theme {
:root {
--peels-surface-page: light-dark(#fbf7ef, #181a20);
--peels-component-radius: 0.65rem;
--peels-blockquote-border: var(--peels-border-accent);
--peels-hr-color: var(--peels-border-accent);
}
/* A context sets variables on itself: only the aside's
buttons become bigger pills. */
aside {
--peels-component-radius: 99px;
--peels-btn-font-scale: 1;
--peels-btn-min-height: 4rem;
}
}
Identity is set in @layer theme — the layer where Peels keeps its themes. light-dark() pairs set light and dark at once; the base derives hovers, soft backgrounds and borders. The --peels-font-* roles work the same way.
/* site.css — your 3 colors + your neutral ramp */
@layer theme {
:root {
--brand: light-dark(oklch(0.55 0.19 150), oklch(0.72 0.17 155));
--action: light-dark(oklch(0.50 0.19 215), oklch(0.70 0.15 215));
--accent: light-dark(oklch(0.58 0.19 330), oklch(0.74 0.15 335));
--neutral-bg: light-dark(#f6f9f7, #101713);
--neutral-bg-elevated: light-dark(#ffffff, #1a231e);
--neutral-bg-sunken: light-dark(#e9f0ec, #0c120f);
--neutral-text: light-dark(#1c2620, #eaf2ed);
--neutral-text-muted: light-dark(#59695f, #a4b3a9);
--neutral-border: light-dark(#d3e0d8, #2f3d35);
}
}
When no variable is enough: your unlayered CSS always wins. Extend an element (article) or build your own ([data-timeline]) on the --peels-* roles — it follows theme and light/dark like a native.
/* site.css — unlayered: always wins */
article:hover {
translate: 0 -2px;
box-shadow: var(--peels-elevation-2);
}
/* Custom element built on the contract */
[data-timeline] {
--_tl-line: var(--peels-border-subtle);
--_tl-dot: var(--peels-border-accent);
padding-inline-start: 1.6rem;
list-style: none;
position: relative;
}
[data-timeline]::before {
content: "";
position: absolute;
inset-block: 0.5em;
border-inline-start: 2px solid var(--_tl-line);
}
[data-timeline] > li::before {
content: "";
position: absolute;
inline-size: 0.8rem;
block-size: 0.8rem;
inset-inline-start: -1.5rem;
inset-block-start: 0.6em;
border-radius: 50%;
background: var(--_tl-dot);
}