No colors of its own

Ambient UI

This page reads the light in the room, then becomes it. Color, contrast, and the pace of the change.

An experiment, and it needs the camera. Every frame becomes 48×36 pixels, then a handful of numbers, then nothing. No video is stored. Nothing leaves this device. Without it, the page reads the time of day instead.

Temperature
6000K (estimated)
Luminance
0.913
Settle
3200ms
01

Sample

The only photograph on the page, and what the room does to it.

Fig. 1 — 4 frames, uncropped. Their colour is the room’s.
02

Measurement

The room as a spec sheet, and the frame behind the numbers.

Source
Fallback (time of day)
Background
rgb(246, 245, 242)
Text
rgb(0, 0, 0)
Luminance
0.913
Temperature
6000K (estimated, by time of day)
Variance
not measured yet
Motion
not measured yet
Distance
not measured yet
Settle
3200ms

Values are literal and unrounded. Nothing measured, nothing shown.

Fig. 2 — the reading, subdivided. Six tiles follow the room. The seventh holds still, so there is always something to compare against.
What the camera sees. Direction and variance are read from the 4×3 grid.
03

Sources

What the page is listening to, and what happens when you take one away.

SourceColourFieldPaceState
not asked
not asked
not asked
not asked
Estimatetime of day

Turn one off and its channels fall to the row beneath it. Nothing here is averaged: a channel goes whole to the highest source that can see it, so the colour on screen is always a colour something actually reported.

04

Mechanism

Where each number comes from.

backgroundtext134.572119.3:1
Estimated background against text, at 19.26:1. Clears WCAG AA's 4.5:1, marked on the scale. Black and white tie at luminance 0.179, and the type switches poles there.
27004000550065009000
No frame, no temperature. The ruler is what would be measured against.
+1−1
Waiting for a frame.
3200ms00.5 ΔE3200ms
Below 0.01 the room is only shimmering, and the page ignores it. Past that, the settle shortens from 3200ms toward 520ms as the light keeps moving. This curve is only the tail. A change further than 0.23 ΔE from the color on screen is also held to 0.22 ΔE per second on its way in, so it takes longer than this. The settle on the spec sheet is the figure that counts both.
0255
No frame to bin yet.
leftright
Nothing scanned yet.
−2minnow
Nothing sampled yet.
framebucketsmean vs. dominant
Waiting for a frame.

Some of these are empty. Without a frame there is nothing to draw, and zero is not a measurement.

05

Architecture

The signal path, and the four decisions the rest of it rests on.

01Observesources/What each source saw, and no more
02ReduceanalyzeFrame.ts48×36 frame, one pass, seven products
03Cascadesources.tsA channel, whole, to the highest source that sees it
04Derivederive.tsEverything that merely follows from what was seen
05Dominant colourdominantColor.tsChroma-weighted quantized mode
06Temperaturetemperature.tsMcCamy's approximation
07Direction, variancespatial.tsCentroid of a 4×3 grid
08Motion, pacedamping.tsOklab ΔE deadbanded, or a room heard
09Targetsmoothing.tsHysteresis: hold until the room moves
10Settlesmoothing.tsFilter and velocity cap, in Oklab
11Text polecolor.tsWCAG contrast, crossfaded
12PaintAmbientTheme.tsxThe reading, as variables

The measurement is a pure library

Everything in src/ambient/ is plain functions over numbers, with no DOM and no React beyond the one hook that owns the camera. That boundary is why the maths is unit-tested at all, and why replacing the view layer would be a rewrite of the rendering rather than of the product. It is the load-bearing decision in the codebase.

One reading, however many sources

A single provider runs the loop and everything reads from it. Without that, each component wanting ambient data would open its own camera stream. It publishes the measurement and the camera handles as separate contexts, because they change at completely different rates — ten times a second against once, when permission is granted — and because most sources are not cameras: a microphone has a permission and no video element, a synthetic scene has neither. The diagrams read from a third context that freezes when they scroll out of view, since a context update propagates past a memo boundary and would otherwise re-render eight figures several times a second for nobody.

The fallback has the same shape

With no camera the page reads time of day and prefers-color-scheme instead, and returns an identically shaped reading. Nothing downstream branches on where the numbers came from; the one thing that differs is that the page says so, and every value it has not actually measured says so too rather than showing a zero.

It renders without JavaScript

The shell is prerendered at build time from the same component tree the browser hydrates, so the page has type and layout before any script runs. That forces the first reading to be deterministic — the server and the client's first paint must agree — which is why the time-of-day fallback is computed a moment after mount rather than during it.

06

Scenes

Five generated rooms, not filmed, each a hard case for one measurement above.

CandleTemperature and motion. Luminance flickers near 8Hz, amplitude reset every 260ms — motion never settles.
TelevisionMotion. A cut every 1.2s between colours placed far apart in Oklab.
Clouded windowDirection. A left-to-right gradient under a 40s luminance drift.
RGB stripColour versus luminance. Hue rotates over 20s at fixed Oklab lightness and chroma — so this tile, luminosity-blended like every image here, is almost still. That is the hard case, drawn: everything moving in it is the thing a luminance measurement cannot see.
Still roomThe noise floor. One fixed scene plus per-pixel sensor noise, nothing else.
07

Findings

What building it turned up. Where a claim was not tested, it says so.

01

Averaging a room converges on grey-brown

The mean of every pixel is the wrong summary of a scene. Average enough hues together and the result tends to a desaturated grey-brown whatever the room is doing — a property of averaging over many hues, not a bug in any one implementation. What the page uses instead is the most populous quantized bucket that clears a chroma floor, so a grey wall with one lit lamp reports the lamp. The mean is still drawn, in the last figure above, purely so the two can be compared.

02

Night Shift predicts what this measures

Night Shift and f.lux warm a display toward evening from a clock and a location: a prediction of what the room is probably doing. This page measures it instead. The two are not opposed, and the product contains both — with no camera it falls back to exactly that method, warming from 6500K at solar noon to 2700K after dark, and says which one is running. The difference in kind is that a clock cannot tell a lamp from a window, or a bright office at nine in the evening from a dark one at noon. What it has in exchange is that it always works, asks no permission, and cannot be corrected out by the camera's own white balance — which, per 03, is the failure mode of measuring.

03

The camera corrects out the signal

Auto white balance exists to remove exactly the colour cast this page sets out to measure, and auto exposure does the same to brightness. Where the browser exposes manual control the page takes it; where it does not, which is most mobile browsers and Safari, the reading is relative to whatever the camera settled on at the start of the session rather than absolute. This is the largest single limit on the whole idea and it is not solvable from inside the page.

04

A still room still drifts

Sensor noise pushes pixels across quantization boundaries, so the winning bucket shifts slightly from frame to frame even when nothing in the room moves. Measured on a textured scene held still, the target wandered a mean step of 0.00020.0019 and a total spread of 0.00350.0067, in Oklab ΔE, across plausible noise amplitudes. Small, but above the threshold at which the filter would have come to rest, so it never did. The theme now holds its target until the room moves 0.01 ΔE away from it. Over 250 still samples the displayed colour changed on 80 frames before that, and 0 after.

05

Re-tuning a transition restarts it

The first version wrote the reading as a CSS target and re-tuned the transition's duration from the room on every sample. A 2200ms transition re-tuned every 200ms restarts eleven times before it can finish, and each restart re-eases from zero velocity. The result reads as jittery and lagging at the same time, which sounds contradictory and is not: it never stops starting, and it never arrives. The settle is now a filter on the sampling clock and CSS only bridges one sample to the next.

06

An exponential filter is distance-invariant

A first-order filter covers the same fraction of any gap in the same time, so it crosses a large distance proportionally faster than a small one. Perceived speed therefore scales with distance: small drifts read as considered, while a light switching off reads as abrupt, from one mechanism with one setting. A velocity cap of 0.22 ΔE per second stretches the large changes. It binds only while the remaining distance is wide, so the small changes that already felt right are untouched by construction.

07

Text is a pole, so it moves in one jump

Contrast picks black or white, never a mid-grey, because inverting a mid-grey yields another mid-grey. That makes the text colour a step function: it changes by the entire range at once, and it changes late, at the moment the background crosses 0.179 luminance — by which point the background is nearly settled and barely moving. Type and ground therefore appeared to run on different clocks. Giving the text its own filter only softens the jump. It now has no clock at all: it is a function of the background currently on screen, crossfaded across a narrow band, so the two are one motion.

08

Most SVG geometry cannot be transitioned

CSS animates cx, cy, r, and a rect's x, y, width and height. It does not animate a line's endpoints, a polyline's points, or a text element's position, and it fails silently rather than erroring. An earlier version of the diagrams transitioned all three: the marker dots eased while their own rules and labels jumped, in every browser. Every moving edge here is now a one-unit rect and every moving label sits in a translated group.

09

Reading the canvas is a stall, not a copy

Pulling pixels back off the GPU costs about the same for twelve pixels as for 1728, because the cost is the pipeline synchronising rather than the bytes moving. Shrinking the capture buys nothing, which is why it stays at 48×36 — small enough to be cheap to process, and no smaller, since smaller would be no cheaper to read.

10

A benchmark that lies about the canvas

Timing getImageData in a loop without redrawing between calls suggests that dropping the willReadFrequently hint makes reads 36× faster. It does not. An unredrawn surface serves a cached snapshot, so the loop measures nothing. Dirtied between reads, the way the page actually works, the hint wins by 2.3×. The wrong version of this measurement is the easy one to take.

11

The expensive part was the arithmetic, not the algorithm

Reading a frame cost 1.23ms and now costs 0.09ms, with every quantity unchanged. Three causes, none of them the method: a pow called three times per pixel where the domain is 256 discrete values and a table is exact; an object allocated per pixel to pass three numbers; and seven separate walks over the buffer, two of which were the same luminance pass run twice.

12

Tabular figures widen the space

In this typeface, turning on tabular figures also widens the space glyph, which blows out word spacing anywhere it touches running text. So tabular is applied only to columns of stacked numerals that have to align, never globally and never to a value containing a space — which rules it out for a string like an rgb() literal.

13 untested

The room barely uses the gamut

Expected, not tested here. Because the camera corrects the cast out (03), the colour that survives is close to neutral, and a wider display gamut would widen a range the measurement was never reaching. If that is right, the useful move is to amplify chroma while holding lightness and hue — at the cost that the rendered colour would no longer be the measured one, which is a trade this page has so far refused.

14

The camera is the demo, not the library

Packaging this as a dependency clarified which half is worth having. The measurement is pure — pixels in, theme out, no DOM, no permission — and a camera is only one way to get pixels, and the most expensive one. A hero image, a video still, an upload or a cover art would all feed the identical function, ask nothing of the user, and work on every device. The camera is what makes the idea legible; it is not what makes it useful.

15 untested

A dark room reads the screen

Expected, not tested here. The front camera faces the person, so in an unlit room the brightest thing in front of it is the display itself. The reading would then partly describe the page's own output — a feedback loop, where the page warms, the reflection warms, and the page warms further. The settle filter and the target threshold both damp it, neither resolves it.

16 untested

Auto-exposure hunting is the likely remaining drift

Expected, not tested here. The threshold in 04 was set against simulated sensor noise. A real camera also hunts: auto exposure and auto white balance re-converge over seconds, which would produce swings both larger and slower than the noise it was sized for. If the page still drifts on real hardware, that is the first place to look, and the threshold rather than the mechanism is what would need raising. Settle times of 3200ms and 520ms would be unaffected.

17

A filter with no first sample charges you for the whole page load

The theme advances by real elapsed time, so that a settle takes the same number of seconds at any sample rate. The first sample of a run has nothing to measure elapsed time against, and measuring it against zero silently means “since the clock started” — every millisecond the page had been open while the permission prompt sat there. Several seconds is many time constants, so the filter covered 99% of the distance in one step: granting the camera snapped the page to the room rather than easing out of the fallback, which is the opposite of what a 3200ms settle promises. The same thing happened on every re-enable, because the teardown reset the motion series but not the sample clock. Both now charge one interval, 100ms.

The bug is ordinary. What is worth recording is that it survived four rounds of fixes to this exact loop, and was found within an hour of the loop being given an interface that could be called without a camera attached. It was never a hard bug — only an unreachable one.

18

A viewBox can quietly falsify a stylesheet

Every rule on this page is one pixel, and the stylesheet says so without qualification. One diagram had been drawing them at 1.6 for months. Each figure is scaled to its grid cell, so a diagram that declared a 200-unit coordinate space where the others declared 320 got a user unit 1.6 times larger — and stroke width and font size are both resolved in user units. Its hairlines and its tick labels were the wrong size against every other figure, from a number that appears nowhere near either.

Nothing could have caught it. The declaration is correct, the stylesheet is correct, and the two are only wrong together. What fixed it was not a check but a shared coordinate space: the eight diagrams now take their width from one constant, so the claim is a property of the system rather than of the care taken in each figure.

19

A wash can animate below the floor of what a screen can show

The field behind the page is painted in the reading’s own text colour at 4.57.5% strength, which is the range the rest of the system’s washes live in. Drifting it slowly enough to read as ambient rather than as an animation put it below the floor: over four seconds the composited pixel moved less than one 8-bit step almost everywhere, and two screenshots taken four seconds apart were byte-identical while the shader was demonstrably redrawing four times a second. Opacity and speed are not independent choices. Below a certain alpha there is a speed that is genuinely animating and genuinely invisible, and no amount of looking at the code shows it.

20

A luminosity blend deletes the scene that tests hue

Every image here is luminosity-blended, so imagery carries no colour of its own and the page keeps its promise that colour only ever enters as a measurement. One of the synthetic scenes rotates hue at fixed Oklab lightness and chroma, which is precisely the case a luminance measurement cannot see — and under that blend the tile is therefore almost perfectly still. The caption had promised a rotation the page is incapable of showing. The blend stayed and the caption changed: everything moving in that tile is exactly what the measurement beside it is blind to, which is a better demonstration than the one intended.

08

As a dependency

An exploration, not a package. What the API would be, and why asking for a camera to pick a colour is a bad trade.

01

The hook

One call returns the reading, the permission state and the function that asks for the camera. The reading has the same shape whether it came from a camera or from the time-of-day fallback, so nothing downstream branches.

import { useAmbient } from 'ambient-mode'

function Page() {
  const { reading, permission, enableCamera } = useAmbient()

  return (
    <main style={{
      background: reading.backgroundCss,
      color: reading.textColorCss,
    }}>
      {permission !== 'granted' && (
        <button onClick={enableCamera}>Read the room</button>
      )}
    </main>
  )
}

02

Without a framework

The capture loop is a subscription, not a component. It writes custom properties and hands back an unsubscribe, which is the whole surface for anything that is not React.

import { readAmbient } from 'ambient-mode/core'

const stop = readAmbient((reading) => {
  const root = document.documentElement.style
  root.setProperty('--ambient-bg', reading.backgroundCss)
  root.setProperty('--ambient-fg', reading.textColorCss)
})

// later
stop()

03

Then it is only CSS

Once the properties are being written, nothing else needs to know the library exists. This is the entire integration for a site that already has a stylesheet, and it degrades to the declared defaults if the loop never starts.

:root {
  --ambient-bg: #f6f5f2;
  --ambient-fg: #141414;
}

body {
  background: var(--ambient-bg);
  color: var(--ambient-fg);
  transition: background-color 100ms linear,
              color 100ms linear;
}

04

The half that has nothing to do with cameras

The measurement is pure: pixels in, theme out, no DOM and no permission. A camera is one way to get pixels and not the most useful one — a hero image, a video still, a cover, an upload. This is the part worth packaging, and it is the part that needs nothing from the user.

import { readFrame } from 'ambient-mode/measure'

// Any ImageData: a canvas, a decoded upload, a video still.
const reading = readFrame(imageData, null)

reading.dominant      // chroma-weighted mode, not the mean
reading.textColor     // black or white, by WCAG contrast
reading.temperatureK  // McCamy's approximation
reading.direction     // where the light is coming from

The prompt costs more than the effect returns

A site that asks for camera access to choose a background colour is making a bad trade on the user's behalf, and most would decline — correctly. That is the honest reason this is an exploration and not a package. Any real version would have to earn the prompt with something larger than theming, or never ask at all and take its pixels from an image.

It costs a camera and a readback

A capture loop keeps the camera powered and pulls pixels off the GPU several times a second. Cheap in absolute terms and not free, which is why the loop stops on a hidden tab and slows when nothing that needs it is on screen. A library would want that behaviour on by default rather than as an optimisation someone remembers.

One change from what is here today

The internals carry colour as { r, g, b } because the measurement works in numbers. The snippets above show backgroundCss, because a consumer wants a string it can hand to CSS. A published version would return both; today it returns the object and a toCssColor helper.