Skip to content

Video color

CSS can’t read a video’s pixels, so it can’t tint a player’s chrome, glow, or caption to match what’s on screen. The video-color plugin samples a playing <video> every few frames and writes its dominant color and a vibrant accent, each as a single #rrggbb color you drop straight into var().

It’s the moving-picture sibling of img-color: the same 16×16 downsample and bucketing, but driven by the video’s own frames instead of a one-shot load. (It keeps to these two colors rather than the full image palette — a six-swatch extraction isn’t worth it 4×/second for an ambient glow.)

Now playing

The frame is read 16×16 on requestVideoFrameCallback (throttled to ~4 Hz), so the caption — var(--live-video), the dominant colour, with text flipped from its own lightness — and the glow and accent chip — var(--live-video-accent), the vibrant blob — both track the picture and stop the moment the video pauses or scrolls offscreen. The video is a canvas-captured stream, so there's no file and no cross-origin taint.

The player’s caption is painted with the video’s dominant color and the glow with its accent, both recomputed ~4×/second as the picture changes — all composed in CSS.

Written on the bound element on the live cadence. The pixels are sRGB, so each is a single hex color — no separate channel props, since CSS pulls channels out of a color for you (relative color syntax, color-mix()).

PropertyWhat it is
--live-videothe dominant color (the most common one)
--live-video-accentthe most vibrant color (reuses the dominant when a frame is essentially grayscale)
.player { box-shadow: 0 0 4rem var(--live-video-accent); }
.caption { background: var(--live-video); }
/* tint, fade, or mix straight from the color */
.scrim { background: oklch(from var(--live-video) l c h / 50%); }
/* contrasting text: flip black/white from the background's own lightness */
.caption { color: oklch(from var(--live-video) clamp(0, (0.6 - l) * 100, 1) 0 0); }

A video is a moving picture, so the cost is in when you sample, not just how:

  1. Sampling rides requestVideoFrameCallback — it fires once per presented frame and stops firing when the video is paused, offscreen, display:none, or in a background tab. An idle video costs nothing, with no IntersectionObserver needed.
  2. On top of that, the pixel read is throttled to ~4 Hz — plenty to track scene cuts for ambient theming, a fraction of the work of reading every frame.
  3. Each read draws the current frame to a 16×16 canvas and buckets those 256 pixels once — the dominant and the accent both fall out of that single pass, the same path as img-color. A paused/poster frame is seeded on attach, and where requestVideoFrameCallback is missing it falls back to the timeupdate event (~4 Hz while playing).
<script type="module">import 'prop-for-that/auto'</script>
<!-- the video-color plugin loads itself the first time the attribute is seen -->
<!-- bind the figure so the caption (a sibling of the video) inherits the color -->
<figure data-props-for="video-color">
<video src="/clip.mp4" muted autoplay loop playsinline></video>
<figcaption></figcaption>
</figure>
import { propsFor, register } from 'prop-for-that'
import { videoColor } from 'prop-for-that/plugins'
register(videoColor)
propsFor(document.querySelector('figure'), ['video-color'])