@cssints/skeleton

Loading placeholders for cssints whose shimmer is in phase across the page, whenever each one was mounted: one chain member of cssints/plugin scope(), after sync-skeleton. No runtime.

import { cn, height, hover, width } from "cssints" with { type: "cssints" };
import { skeleton } from "@cssints/skeleton" with { type: "cssints" };

export const line = cn(skeleton(), height("1em"), width("12rem"));
export const avatar = cn(skeleton().radius("50%"), width("3rem"), height("3rem"));
export const branded = cn(skeleton().color("#e4e4f0").highlight("#f4f4fa"), height("8rem"));
export const card = cn(skeleton(), hover(skeleton().highlight("white")));
@property --skeleton-p {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 100%;
}
@layer _.k {
  @keyframes kf-1a2b3c4d-skeleton {
    from {
      --skeleton-p: 100%;
    }
    to {
      --skeleton-p: 0%;
    }
  }
  @media (prefers-reduced-motion: no-preference) {
    :root:has(._skeleton-clock) {
      animation: kf-1a2b3c4d-skeleton 1.5s ease-in-out infinite;
    }
  }
  ._skeleton-core {
    border-radius: var(--skeleton-r, 0.25rem);
    background-position: var(--skeleton-p) 0;
    background-image: linear-gradient(
      90deg,
      var(--skeleton-c, color-mix(in oklab, currentColor 12%, transparent)) 40%,
      var(--skeleton-h, color-mix(in oklab, currentColor 4%, transparent)) 50%,
      var(--skeleton-c, color-mix(in oklab, currentColor 12%, transparent)) 60%
    );
    background-size: 300% 100%;
    background-attachment: fixed;
  }
  /* only for targets without color-mix() (the defaults have Chrome 109) */
  @supports not (color: color-mix(in oklab, red, red)) {
    ._skeleton-core {
      background-image: linear-gradient(
        90deg,
        var(--skeleton-c, rgb(128 128 128 / 20%)) 40%,
        var(--skeleton-h, rgb(128 128 128 / 8%)) 50%,
        var(--skeleton-c, rgb(128 128 128 / 20%)) 60%
      );
    }
  }
}
@layer _.a {
  ._kkw3xj {
    --skeleton-r: 50%;
  }
  /* ... */
}

skeleton() is "_skeleton-core _skeleton-clock"; each method adds one class. Both kernels are written as styles, no raw CSS: the clock is media("(prefers-reduced-motion: no-preference)")(nest(":root:has(&)")(animation(keyframes({ from: vars({ "--_p": "100%" }), to: vars({ "--_p": "0%" }) }), "1.5s ease-in-out infinite"))), and only its @property registration is a string (atProperty). The keyframes name is a hash of the steps with the scope's name after it.

How it stays in sync

A CSS animation starts when its element gets its style, so a skeleton that mounts 700ms after another runs 700ms behind it, and the page flickers out of step. No skeleton here runs an animation. One animation on :root moves a registered, inherited percentage, --skeleton-p, and every skeleton reads it as its background-position: there is one clock, so there is one phase, in every browser that has @property. The animation is on only while a skeleton is in the document (:root:has(._skeleton-clock)), so a page with none runs nothing; when the last one goes and a new one comes, the clock starts again, with nothing to be out of step with.

The gradient is three viewports wide and fixed to the viewport, so the band sweeps the page once, across every skeleton, as one picture (the same pixel at the same viewport x, as the browser test reads it). Where fixed has no effect (iOS Safari, which BCD marks as a partial implementation) each skeleton has a band of its own size, still on the same clock: in phase in time, not in space.

The other ways were weighed and left: background-attachment: fixed with an animation on each skeleton lines up the space but not the time (each animation still starts at its own mount); a negative animation-delay from document.timeline.currentTime is script; CSS has no document timeline to name in animation-timeline (only scroll and view timelines), and animation-delay: calc() cannot read the time.

Rules

<length-percentage [0,∞]>, skeleton.color: unexpected "4px" in "4px", expected `.

Browsers

bun run test:browsers mounts a 600px skeleton, then 700ms later a 300px one under it and a control that runs the same keyframes on itself, pauses the animations and reads a row of each from a screenshot:

Browser Animations A and B (300 columns) Control Reduced motion
Chrome (installed channel) one, on :root 0 columns differ 132 none, the base colour
Firefox (Playwright build) one, on :root 0 columns differ 132 none, the base colour
WebKit (Playwright trunk build) one, on :root 0 columns differ 132 none, the base colour

@property (Chrome 85, Firefox 128, Safari 16.4) and :has() (Chrome 105, Firefox 121, Safari 15.4) are the kernel's requires: a target that lacks one is a build warning. Without @property the percentage animates as a discrete value and the band never shows (the base colour, still); without :has() nothing animates. For targets without color-mix() the build adds the grey defaults under @supports not, and only then.

Limits

cd packages/skeleton && bun run test           # fixtures through the engine (output, conflicts, errors); bun run typecheck for the type test
cd packages/skeleton && bun run test:browsers  # the phase and reduced motion in Chrome, Firefox and WebKit

Credits

The shared clock follows sync-skeleton (MIT, Corbin Crutchley). The CSS is written here; no code is copied.