Peels

Get started

One CSS file, one attribute on <html>, semantic HTML. No dependencies.

The files

peels.css

All of Peels + the shipped theme. Standard use — ~140 KB.

peels-extended.css

The same + all optional themes (color, type, density) — for a theme switcher.

The minimum

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.

The bare state

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 -->

A precise context

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;
  }
}

Your three colors

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);
  }
}

Beyond variables

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);
}