Skip to content

Component

texture

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and texture.css. No scripts.

Source

Copy both files, or run npx nojsui add texture.

Source for texture
<!-- aria-hidden and pointer-events: none — this is decoration. It must never
     intercept a click, and a screen reader must never meet it.

     The layer is position: fixed with a negative z-index, so it sits behind
     the page and does not scroll. Everything else here is demo dressing. -->
<div class="sk-texture" data-texture="dots" aria-hidden="true"></div>

<div class="sk-texture__content">
  <h3 class="sk-texture__title">Dots, behind everything</h3>
  <p class="sk-texture__text">
    The ink is a <code>color-mix()</code> of the text colour, so it inverts
    with the theme on its own — there is no dark-mode rule in the stylesheet.
  </p>
  <p class="sk-texture__text">
    <code>data-texture</code> also takes <code>grid</code> and
    <code>glow</code>. There is deliberately no <code>noise</code>: it is the
    one pattern CSS cannot draw without an embedded SVG filter.
  </p>
</div>
/* .sk-texture — a fixed decorative layer behind everything.

   THE IDEA WORTH STEALING FROM THIS FILE: the ink colour is not chosen, it is
   MIXED FROM THE TEXT COLOUR. A decoration picked as a literal needs a second
   value for dark mode and a media query to switch them; one mixed from a token
   inverts on its own when the scheme flips, because the token already did.
   There is no dark-mode block in this file and there does not need to be one.

   aria-hidden in the markup, pointer-events: none and a negative z-index here:
   this is decoration, and it must never intercept a click or reach the
   accessibility tree.

   Specs:
   - color-mix() ........... https://drafts.csswg.org/css-color-5/#color-mix
   - radial-gradient() ..... https://drafts.csswg.org/css-images-3/#radial-gradients
   - repeating-linear-gradient
                             https://drafts.csswg.org/css-images-3/#repeating-gradients */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-texture {
  --_color: var(--sk-texture-color, color-mix(in oklab, var(--sk-color-text) 7%, transparent));
  --_size: var(--sk-texture-size, 1.75rem);
  --_opacity: var(--sk-texture-opacity, 1);

  position: fixed;
  inset: 0;
  z-index: -1;
  opacity: var(--_opacity);
  pointer-events: none;
}

/* Dots: one radial-gradient stop, tiled by background-size. The dot is a
   sixth of the cell, which reads as texture rather than as pattern. */
.sk-texture[data-texture="dots"] {
  background-image: radial-gradient(circle at center, var(--_color) 16%, transparent 17%);
  background-size: var(--_size) var(--_size);
}

/* Grid: two repeating gradients, one per axis. Hairlines rather than a
   border, so the cell size is the only thing a consumer has to think about. */
.sk-texture[data-texture="grid"] {
  background-image:
    repeating-linear-gradient(to right, var(--_color) 0 1px, transparent 1px var(--_size)),
    repeating-linear-gradient(to bottom, var(--_color) 0 1px, transparent 1px var(--_size));
}

/* Glow: a single soft radial wash. --_size is a radius here rather than a
   cell, which is the one place the token means something different per
   variant — the README says so. */
.sk-texture[data-texture="glow"] {
  background-image: radial-gradient(
    circle at 50% 0%,
    var(--_color) 0,
    transparent calc(var(--_size) * 20)
  );
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour

   The demo needs something in front of the texture to prove it sits behind.
   Delete this when applying .sk-texture to your own page.
--------------------------------------------------------------------------- */

.sk-texture__content {
  display: grid;
  gap: var(--sk-space-xs);
  max-inline-size: 30rem;
  padding: var(--sk-space-lg);
}

.sk-texture__title {
  font-size: var(--sk-text-lg);
  letter-spacing: var(--sk-tracking-tight);
}

.sk-texture__text {
  margin: 0;
  color: var(--sk-color-text-muted);
  font-size: var(--sk-text-sm);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS

Usage

.sk-texture is a decorative backdrop: aria-hidden, pointer-events: none, a negative z-index, and position: fixed so it sits behind everything and does not scroll with the page.

<div class="sk-texture" data-texture="dots" aria-hidden="true"></div>

data-texture takes dots, grid, or glow.

data-textureMade of
dotsOne radial-gradient() stop, tiled by background-size
gridTwo repeating-linear-gradient()s, one per axis, drawn as hairlines
glowA single soft radial-gradient() wash anchored to the top

How it works

The default ink colour is color-mix(in oklab, var(--sk-color-text) 7%, transparent) — not a picked value, a mix. Because it is derived from the text colour rather than chosen outright, it inverts automatically when the colour scheme flips: there is no @media (prefers-color-scheme) block in this file, no second value for dark mode, no [data-theme] override. --sk-color-text already changes with the scheme, so anything mixed from it changes too.

This generalises past this one component: any decoration that should track the theme — a hairline, a wash, a shadow — is better mixed from a token than picked as a literal. A literal needs a dark-mode rule to correct it later; a mix never drifts from the token it was built from.

Why there is no noise variant

Dots, grid and glow are all gradients — background-image paints them in one declaration each. Noise is not a gradient. CSS alone cannot draw it; the only way to get grainy noise without JavaScript is an inline SVG feTurbulence filter, applied as a data URI:

.sk-texture[data-texture="noise"] {
  filter: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/></filter></svg>#n');
}

That would be the largest thing in the component by a wide margin, and it is a different kind of primitive: a filter recomputes and composites the layer rather than painting a value, which is a real cost on a position: fixed layer covering the full viewport. This is a deliberate omission, not a gap — copy the recipe above if you want it.

Keyboard contract

There is nothing to operate. .sk-texture is decoration: aria-hidden="true" keeps it out of the accessibility tree, and pointer-events: none keeps it out of the hit-testing order. No key does anything to it, and none should.

Accessibility notes

aria-hidden="true" and pointer-events: none are both required in the markup and CSS respectively — either alone is not enough. aria-hidden removes it from the accessibility tree; pointer-events: none keeps a sighted pointer user from ever landing a click on it, since the layer sits position: fixed above the document flow. The negative z-index keeps it behind real content so it never obscures anything.

Degradation

color-mix() is Baseline widely available. There is no @supports block in this component and nothing to degrade.

Feature usedBaseline statusBehavior without it
color-mix()Widely availableN/A — no browser in the support policy lacks it

Theming

PropertyDefaultControls
--sk-texture-colora color-mix() of --sk-color-textInk colour of the pattern
--sk-texture-size1.75remCell size for dots and grid; radius for glow
--sk-texture-opacity1Overall strength, on top of the colour’s own alpha