LivePhoto.
Print a moment. Scan it. Watch it come alive. A mobile web app that plays a video on top of its own printed photo — tracked to the paper, live through the camera, no special hardware. Every piece below is rebuilt from the app source.
It is deployed and real — but it is a family album, so it asks who you are.
AR photo library
LivePhoto
Print a moment. Scan it. Watch it come alive.
Today
Yesterday
An editorial gallery: pure black, oversized Outfit display type, and image-only tiles — the title lives in the detail card, so the photos are the whole surface. Tiles flow into a two-column masonry by the shortest-column rule, with heights computed from stored frame dimensions so placement is deterministic before a single image loads.
The header collapses like a native large-title bar. Crossing the scroll threshold snaps layout in one reflow; the visible motion is a FLIP animation on transform only, so it composites at the display's own refresh rate — and the height the collapse frees is re-added as bottom padding so short pages can't flip-flop.
Colour
Type
Display
clamp(32–40) / 600 / -.03emDay label
13 / 500 · OutfitSubtitle
14 / 400 / 1.55Source
// Pinterest-style masonry: place each tile (in order) into the currently// shortest column. Heights are computed from the stored frame dimensions,// so placement is deterministic and needs no image loads.const COLS = 2;const GAP_FRAC = 0.06; // ~10px gap relative to column widthfor (const item of items) { let target = 0; for (let c = 1; c < COLS; c++) { if (heights[c] < heights[target]) target = c; // strict < → leftmost wins ties } heights[target] += tileAspect(item) + GAP_FRAC; cols[target].append(renderTile(item));}// reserve the tile's true aspect before the image loads — placement math// stays honest and the grid never shiftsfig.style.aspectRatio = `${item.frameWidth || 4} / ${item.frameHeight || 5}`;
Tapping a tile morphs this card up from the bottom edge while the page behind it blurs — and the blur is on the page content itself (the app canvas) because iOS Safari clips fixed layers to the layout viewport and an overlay's backdrop-filter can never reach the strip behind the collapsing toolbar. The card is deliberately shadowless for the same reason: a large shadow ends in a visible brightness seam right above that strip.
The public/private row is one button: tapping it flips visibility optimistically, the icon, label and hint all roll together through a clipped window (the same direction the switch travels), and the whole thing rolls back if the server write fails.
Colour
Type
Card title
21 / 600 / -.02em · OutfitMeta line
12.5 / 400 / .01emToggle label
14 / 600 / -.01emSource
/* The dim + blur lives on the page content itself, not on the overlay: iOS Safari clips fixed layers to the layout viewport, so an overlay backdrop-filter can never reach the strip behind the collapsible toolbar — the page canvas can. */body.modal-open #app { filter: blur(14px) saturate(140%) brightness(0.55);}/* and the card springs up from the bottom edge — modal.ts */panel.animate( [ { opacity: 0, transform: "translateY(24px) scale(0.96)" }, { opacity: 1, transform: "none" }, ], { duration: 380, easing: "cubic-bezier(0.34, 1.2, 0.4, 1)", fill: "both" },);The same floating card as the photo modal, holding a stack of views instead of a photo. Navigating to Appearance or Sign out FLIPs the panel's height between the two measured states while the old view blurs out and the new one rises in — the whole panel morphs as one piece.
Rows are soft filled rectangles on hover — the menu register, quieter than a pill. Appearance shows its current value inline ("System") so the submenu is optional reading, and the destructive row is red before you ever enter it.
Colour
Type
Menu row
15 / 550 · InterTrailing value
13 / 500Source
/** swap views; dir 1 = forward (new rises from below), -1 = back */function render(view: MenuViewBuilder, dir: 1 | -1): void { const h0 = viewWrap.offsetHeight; // take the old view out of flow so the wrap's height becomes the new // view's height, then FLIP the height between the two measurements prev.style.position = "absolute"; viewWrap.append(next); const h1 = next.offsetHeight; viewWrap.animate([{ height: `${h0}px` }, { height: `${h1}px` }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", }); prev.animate([ { opacity: 1, transform: "none", filter: "blur(0px)" }, { opacity: 0, transform: `translateY(${-8 * dir}px)`, filter: "blur(4px)" }, ], { duration: 180, easing: "ease", fill: "forwards" });}
The designed replacement for window.confirm(): a spring bottom sheet that
resolves a promise — false on any dismiss, and there are three: the backdrop,
Escape, and a 60px swipe down on the sheet itself.
Deleting really is permanent here — the bucket is the only copy — so the destructive action is the one place the app uses a solid red fill. Cancel is a ghost: the quiet option is the safe one, and it is directly under your thumb.
Colour
Type
Sheet title
19 / 600 / -.015em · OutfitMessage
12.5 / 400Button
15 / 600 / -.01emSource
/** Designed replacement for window.confirm(). Resolves false on any dismiss. */export function confirmSheet(opts: ConfirmOpts): Promise<boolean> { ... }// swipe-down to dismisssheet.addEventListener("touchmove", (ev) => { if (startY !== null && ev.touches[0].clientY - startY > 60) { startY = null; close(); }}, { passive: true });const confirmBtn = el( "button", { class: `btn ${opts.danger === false ? "primary" : "danger-solid"}` }, [...(opts.confirmIcon ? [icon(opts.confirmIcon)] : []), opts.confirmLabel ?? "Confirm"],);The one inverted element on the library screen: white on black, docked where a thumb lives. Scanning is the everyday action — creating is occasional, so it hides in the header menu while Scan floats.
It hides directionally: scrolling down tucks it 160% below the screen edge, scrolling
back up even two pixels brings it back. Arrival is a springed dock-in with a 0.2s delay,
using backwards fill on purpose — the entrance must not retain its final
transform or it would override the hidden state.
Colour
Type
Scan a photo
15 / 650 / -.01em · InterSource
// Scan is the everyday action — a single confident floating action,// distinct from the occasional "New" in the header.const scanBtn = el("button", { class: "fab" }, [icon("scan"), "Scan a photo"]);// Hide the Scan FAB while scrolling down; bring it back on scroll-up.if (y > lastY + 2 && y > 48) scanBtn.classList.add("hidden");else if (y < lastY - 2) scanBtn.classList.remove("hidden");/* directional hide: tucked below the screen while scrolling down */.fab.hidden { transform: translateX(-50%) translateY(160%); opacity: 0;}One toast at a time, newest wins — a new message removes the old node outright rather than queueing, because on a phone a queue of stale toasts is worse than no toast. It drops in from the top edge with a spring, and leaves by drifting back up.
The kind is carried by the icon chip alone — sparkle, check, alert on tinted circles — while the pill itself stays the same frosted near-black in every state. Single line always: the chip widens up to 94vw rather than wrapping.
Colour
Type
Toast message
12 / 550 / 1.3 · InterSource
const TOAST_ICONS: Record<ToastKind, IconName> = { info: "sparkle", success: "check", error: "alert",};export function toast(message: string, kind: ToastKind = "info", durationMs = 3200): void { document.querySelector(".toast")?.remove(); // one at a time, newest wins const node = el("div", { class: `toast ${kind}`, role: "status" }, [ el("span", { class: "toast-icon" }, [icon(TOAST_ICONS[kind], 14)]), el("span", { class: "toast-msg", text: message }), ]); document.body.append(node);}Pixels do the waiting here. The dither loader is a 4×4 grid brightening in Bayer-matrix order — the halftone cousin of a spinner — with negative animation delays so every cell starts mid-cycle and the grid never opens on a dark warm-up frame. The dot matrix rolls a diagonal wave from the top-left. The ASCII loader is a single monospace glyph cycling a terminal braille spinner inside a fixed 1em box, so swapping frames never nudges the label beside it.
Each has a job: dither for inline waits, dot matrix for the long compile, ASCII inside the Save button — where the same glyph node survives every label change, so the spinner never visibly restarts.
Colour
Type
⠧ braille frames
1em mono box · 10 framesSource
/** Bayer 4×4 threshold matrix, row-major: cell i brightens at step BAYER[i]/16. */const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];// negative delays start every cell mid-cycle — no dark warm-up frameconst cells = BAYER.map((step) => el("span", { style: `animation-delay:${(-(step / 16) * speed).toFixed(3)}s` }),);// dot matrix: diagonal distance from the top-left sets the phaseconst delay = ((x + y) / (2 * (n - 1))) * speed;// ascii: a single monospace glyph cycling a braille spinnerconst ASCII_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];Create
New LivePhoto
Choose a target
Print a frame from the videoRecommended
Pick a frame, print it, and the video plays perfectly aligned on top of it. The digital frame is noise-free, so recognition is most reliable.
Use an existing printed photo
Point the camera at the photo — it is detected, captured and cropped automatically, from a few angles.
The create flow's fork, set as editorial option rows: hairline rules above and below, the copy carrying a vertical spine that lines up with the gallery captions, and the arrow translating 4px on hover. The rows stand on those rules alone — they are the structure.
The step indicator above is discrete — one hairline segment per step, filling as you advance — replacing a numeric "02 / 04" counter. Progress reads at a glance without asking you to do division.
Colour
Type
Option title
17 / 600 / -.015em · OutfitOption body
13 / 400 / 1.55Recommended tag
11 / 600 · pillSource
const frameRow = el("button", { class: "option-row" }, [ el("div", { class: "option-text" }, [ el("h3", {}, ["Print a frame from the video", el("span", { class: "tag", text: "Recommended" })]), el("p", { text: "Pick a frame, print it, and the video plays perfectly aligned on top of it. The digital frame is noise-free, so recognition is most reliable.", }), ]), icon("arrow-right"),]);const physicalRow = el("button", { class: "option-row" }, [ ... el("p", { text: "Point the camera at the photo — it is detected, captured and cropped automatically, from a few angles.", }),]);
The frame scrubber, and the app's replacement for <input type="range">:
a strip of vertical ticks on a shared baseline that swell upward around the current
position on a gaussian falloff. The needle is one of the ticks — the tick nearest
the position becomes a solid crest, 4px taller than the swell but contained in the
ruler, so nothing ever pokes up into the video frame above it.
This chip is one frame of that canvas, produced by the component's own constants: 9px pitch, 12px base ticks, an 18px swell with a 26px radius, alphas 0.5 behind the needle and 0.22 ahead of it. The value chip below rides the crest and shows a sliding-number readout in the app.
Colour
Type
3.42s
13 / 600 / tabularSource
// ticks share a baseline and only ever grow upward: ____|____for (let i = 0; i <= count; i++) { const x = pad + i * spacing; if (i === nearest) { // active position: a solid crest tick, a touch taller than the swell // but contained within the ruler — never shoots up into the frame const needleH = BASE_TICK_H * dpr + bumpH + 4 * dpr; ctx.fillRect(x - w / 2, baseline - needleH, w, needleH); continue; } const d = (x - shownX) / (BUMP_RADIUS_PX * dpr); const bump = Math.exp(-d * d); const h = BASE_TICK_H * dpr + bumpH * bump; const baseAlpha = x <= shownX ? 0.5 : 0.22; ctx.fillStyle = `rgba(${r},${g},${b},${baseAlpha + (1 - baseAlpha) * bump})`; ctx.fillRect(x - w / 2, baseline - h, w, h);}Enrolling a physical photo captures it from three angles — straight on, then ±30° — and this Face ID-style gauge is how the app asks for the tilt without a paragraph of instructions. Ticks span −40…+40° and fill from centre as the phone rotates; the target zone is pre-marked taller, and the moment the needle enters it, zone and needle go green.
The angles come from the orientation sensor, zeroed against the straight-on capture. Haptics mirror the gauge — a tick each ~4° of rotation, a firm double pulse on reaching the zone — so you can watch the photo instead of the strip.
Colour
Type
Hint pill
13.5 / 600 · frostedSource
const RANGE = 40; // degrees left/right of centerconst TICK_STEP = 2.5;let color = "rgba(255,255,255,0.22)";let tickH = 13 * dpr;if (filled) color = "rgba(255,255,255,0.95)";if (inBand) { tickH = 17 * dpr; color = satisfied ? "rgba(74,222,128,0.95)" : filled ? "rgba(255,255,255,1)" : "rgba(255,255,255,0.4)";}if (isCenter) tickH = 20 * dpr;// haptics mirror the gauge: a tick each ~4° of rotation, a firm// double pulse when the target zone is reached — create.tsconst notch = Math.round(yaw / 4);if (angleOk && !wasOk) hapticSuccess();
A document scanner built from scratch on the web platform — plain TypeScript over a canvas. Nine times a second the camera frame is shrunk to 192px and a gradient-restricted Hough transform looks for the photo's quadrilateral; candidate edges only count if real edge pixels cover enough of the perimeter, which is what rejects table edges and shadows.
The overlay outline lerps toward each detection, fills in as stability accrues, and turns green when the quad has held for 1.4 seconds — then the capture takes itself. The full-resolution frame is rectified with a homography, and uniform print borders (Instax-style) are trimmed automatically when clearly present. The shutter button is the manual override, for when you would rather press it yourself.
Colour
Type
Hold steady…
13.5 / 600 · frosted pillSource
const STABLE_CAPTURE_MS = 1400;detectTimer = window.setInterval(() => { const frame = grabFrame(192); // detection runs small, ~9 Hz const detected = detectQuadFromPixels(frame); const { quad, stableMs } = tracker.update(detected, now); if (quad && angleOk && stableMs > STABLE_CAPTURE_MS && ...) { void autoCapture(quad); // no shutter press needed }}, 110);// full-res capture: rectify the quad with a homography, then cut// uniform print borders (Instax-style) when clearly presentconst rect = rectifyQuad(full, quad, 1280);const trim = detectUniformBorder(rect);if (trim.confident) canvas = cropCanvas(canvas, trim.insets, 0.006);Building the fingerprint
Analysing 1 image… this takes a moment
Compiling a target takes seconds to tens of seconds on a phone, so the wait earns real feedback: a 1px determinate hairline of progress (the app's progress bars are 1px — a rule that fills), and then a verdict.
The verdict is the matcher's own feature count, banded: 100 points or more is Good, 40 is Fair, below that Poor — a photo that will frustrate scanning gets told at enrollment, not discovered at the dinner table. The badge is an outline drawn in the verdict's colour; the number stays the loudest thing.
Colour
Type
Badge
12 / 600 · outlined pillCount
13 / 400 / .02emSource
/** Total matcher feature count — the enrollment quality signal. */export function countMatchingFeatures(target: MindTarget): number { const best = largestKeyframe(target.matchingData); return (best.maximaPoints?.length ?? 0) + (best.minimaPoints?.length ?? 0);}export function qualityForFeatureCount(count: number): Quality { if (count >= 100) return "good"; if (count >= 40) return "fair"; return "poor";}// Compilation is CPU-heavy (seconds to tens of seconds per image on a// phone) and always happens at enrollment time on the creator's device —// never on the scanning device.compiled = await compileTargets(inputs, (p) => { bar.style.width = `${p}%`; });
Three Sisters The scanner's status readout is a dynamic island: a frosted near-black capsule that spring-morphs between states. The shell animates real measured width and height in pixels, so the capsule radius holds its curve, while the old content blurs out and the new one blurs in with a slight overshoot. Every state locks to the 44px control height, so the capsule never jumps when the thumbnail appears.
Three states, three loaders' worth of meaning: dither while targets download, dot-matrix wave while searching, and on lock it expands into a now-playing chip — the matched photo's thumbnail, its name, and a four-bar equalizer bouncing out of phase. Motion carries "playing" — the bars are the label. The capsule on this shelf runs the real loop: preparing → searching → locked, with the app's own timings and easings.
Colour
Type
Island label
14 / 600 / -.01em · InterSource
// measure off-screen first — the shell's size transition needs a targetcontent.style.visibility = "hidden";shell.append(content);const w = content.offsetWidth;const h = content.offsetHeight;content.style.visibility = "";shell.style.width = `${w}px`; // the shell springs to the new content'sshell.style.height = `${h}px`; // measured size — real width/height, so // the capsule radius never distortsprev.animate([ { opacity: 1, transform: "translate(-50%, -50%) scale(1)", filter: "blur(0px)" }, { opacity: 0, transform: "translate(-50%, -50%) scale(0.88)", filter: "blur(5px)" },], { duration: 160, easing: "ease-in", fill: "forwards" });Nothing here yet.
Create a LivePhoto from a video you love, print its frame, then scan it.
New LivePhoto
An empty library plays a pixel film: real footage quantized into a 72×96 grid
of 9px cells, each holding one of five palette bands, playing at 12fps behind the
message. The field behind this chip is the film itself — windmill.pxf, the
actual asset the app ships, decoded from its 4-bit format and played by the same rules
the app draws with, in its assigned palette, meadow: grass, sky, cloud.
The copy stays legible over a full-strength field without a scrim rectangle: a 1.5px background-coloured text stroke for a crisp edge, plus three tight text-shadows that darken the field only around each glyph. The shade hugs the letters, so it reads as type sitting in the field rather than a panel floating on it.
Colour
Type
Nothing here yet.
clamp(24–29) / 600 / -.02emSource
/** * Named 5-step palettes for PixelFilm. A pixel film only stores a band index * (1 = darkest visible … 5 = brightest) per cell — the hue is chosen here at * render time, so a single .pxf can be re-coloured just by naming a palette. */const PALETTES = { signal: ["#273357", "#3b5bd9", "#f5c518", "#e0492a", "#d8ff00"], // meadow — green grass, blue sky, white cloud (windmill scene) meadow: ["#243318", "#4f8a37", "#8fc45a", "#7fb2e0", "#ffffff"], ...};// legibility comes from a shade that hugs the glyphs — style.csspaint-order: stroke fill;-webkit-text-stroke: 1.5px var(--bg);text-shadow: 0 0 3px var(--empty-shade), 0 0 6px ..., 0 0 9px ...;LivePhoto makes a printed photograph play. You pick a video, print one of its frames, and from then on pointing the app's camera at that print overlays the video exactly on the paper — matched to its position, tracked through perspective, picking up the moment the print re-enters the frame. Xiaomi ships this as a phone feature backed by their gallery; this is the same experience rebuilt on the open web, end to end: one page you open, one camera permission, and a photo on your fridge that turns into the moment it came from.
Scanning a photo
Open the scanner and every photo in the album is a candidate at once — each item's compiled target is downloaded and merged into a single tracker, so there is nothing to choose before you point. A frosted capsule narrates the wait like a dynamic island: a dithered pixel grid while targets download, a rolling dot-matrix wave while searching, and on lock it expands into a now-playing chip — thumbnail, name, a four-bar equalizer.
The lock itself is engineered for the first impression: the chosen frame appears pinned to the print instantly as a poster, and the video takes over from its own first frame — the printed image and frame zero are the same picture, so the handoff is invisible. Decoders are warmed at startup (a muted play, parked back at frame 0) so the first lock shows motion immediately instead of a decode stall. Lose the print and re-find it within 5 seconds and playback resumes where it was; longer, and it restarts from the top. The clip is prefetched as a file the moment a lock happens, so the share button can hand it to the iOS share sheet inside the tap's user gesture — awaiting a network fetch there would burn the gesture and iOS would refuse the sheet.
Making one
Creating is a four-step editorial flow, and its calendar is honest: the video starts uploading in the background the moment you pick it, so the final Save is a metadata write that lands in a blink. Two ways to make a target: print a frame straight from the video — the recommended path, because a digital frame is noise-free — or enroll a photo you already have. The second path is a small document scanner: a gradient-restricted Hough transform finds the print's quadrilateral nine times a second, an outline settles over it, and after 1.4 seconds of stability the capture takes itself, rectifies the perspective, and trims Instax-style borders. It asks for three captures — straight on, then tilted ±30°, coached by a Face ID-style gauge with haptic ticks — so recognition survives being looked at from an angle.
Then the phone does the heavy lifting that would normally be a server's job: the MindAR compiler runs in the browser at enrollment, distilling the image into a feature fingerprint. It has to — the compiler needs tfjs and WebGL, which the Workers runtime doesn't have — and it turns out to be the right architecture anyway: scanning devices just download the precompiled result. The compile ends in a verdict from the matcher's own arithmetic: 100+ feature points is Good, 40 is Fair, fewer is told to pick a busier photo. Every step of the half-built session persists to IndexedDB, so a phone call mid-enrollment leaves the session waiting for twelve hours.
How it works
The frontend is deliberately plain: vanilla TypeScript views over a 45-line
el() helper, three.js for the overlay, and MindAR for image tracking — no
framework, and three real dependencies. The backend is one Cloudflare Worker in front of a
private R2 bucket. There are no public object URLs: every byte of media streams through the
Worker with an ownership check, and video responses honour Range requests because iOS
refuses to play video from a server that doesn't. Auth is Sign in with Apple implemented
from scratch on WebCrypto — Apple's client secret is an ES256 JWT you mint and sign
yourself, and requesting any scope forces the callback into a cross-site form POST, two
quirks that auth SaaS normally hides. Sessions are stateless HMAC-signed cookies; the
family whitelist is re-checked on every request, so removing an email locks that person out
immediately. The whitelist is the whole user store.
Teaching the matcher to say no
The hardest bug in the project was a matcher that was too eager to say yes. Stock MindAR accepts a detection with 6 inlier feature matches and never validates the recovered homography — so a thumbnail of the target inside a screenshot, or a patch of dense UI text, would lock with a wildly wrong pose. The fix is a patched copy of the matching core, swapped in by a Vite plugin so both the compiler and the tracking worker use it: the inlier floor rises to 15 and — the part stock forgets — is re-checked on the final refined set; the projected quad must be convex, unmirrored, not razor-thin, and cover at least 5% of the camera frame; and the inliers must be spread out, so a tight cluster of points can't dictate a full-frame pose. A standalone bench page compiles a target and runs the real matcher over screen-recording frames under both configs: stock locks onto them, strict rejects each with a named reason. Its observed floor: with 15 inliers required, a print smaller than roughly a tenth of the frame no longer locks — which is the point.
Holding still
MindAR re-estimates the pose fresh every frame, and the estimate is noisy — so the overlay jitters even when the phone is dead still, and the video looks like a sticker coming loose exactly when it should look printed. A plain low-pass can't fix it: enough smoothing to kill the jitter makes real motion visibly trail. The smoother in front of the renderer separates the two regimes instead. A low-pass-filtered velocity estimate tells jitter from motion — jitter is zero-mean, so its filtered velocity is ~0; real motion sustains one. When still, a dead-band (1.2% of marker size, 0.34° of rotation) freezes the pose outright; as motion ramps up the dead-band fades to zero and the filter cutoff opens from 0.3 Hz to 8 Hz, so the overlay lands where the pose says, on time. Position and scale filter as scalars, rotation as a gated slerp — the overlay can drift, but it can never wobble non-rigidly. Rock-steady when the camera is still, snappy when it moves, and every constant tunable from the URL on real hardware.
Both of these fights — the matcher taught to say no, the overlay taught to hold still — plus the first-frame budget that makes a lock feel instant, get a longer write-up with all the numbers defended: How I improved AR image tracking.
One design language, pure black
The app calls its system an editorial gallery: pure black, hairline rules for structure, oversized Outfit display type over Inter body text, and sentence-case labels that lean on weight and dim ink for hierarchy. The library is photographs on black, edge to edge. What the system spends its budget on is motion and state: a large-title header that collapses by FLIP on the compositor, a modal that blurs the page canvas itself (because iOS clips fixed overlays), an island that spring-morphs between measured sizes, and three loaders — Bayer dither, dot-matrix wave, braille spinner — where another app would own one spinner. The whole thing is 2,319 lines of CSS and zero component libraries. There is a light theme too — the same tokens inverted, camera surfaces excepted — and the floating cards on the shelf above wear it.
Even the ornament is engineered: the empty state's drifting pixel-art field is real footage
quantized into a 4-bit format (.pxf — band indices only, ~166 KB for
eight seconds), recoloured at render time by named five-step palettes tuned per clip —
meadow for the windmill, dawn for the sunrise. The film on the chip above
is an actual frame of the shipped asset.
What it is, exactly
A stage-1 prototype whose focus areas are written at the top of the README — detection accuracy, first-frame alignment, latency, multi-angle enrollment — and a family album, whitelisted to a handful of Apple IDs, run for the people in the photos. Search and filtering are parked in a commented-out block; desktop gets a soft gate rather than support; the matcher's thresholds were tuned by pointing phones at prints, not by a benchmark suite. And the honest caveat: the recognition bench that proves the matcher's strictness is also how I know its limit — hold the print far enough away and the scanner simply, correctly, declines.
The album's clips are a family mix — stock footage and little pixel-art loops; the Mona Lisa enrolls from a postcard, which feels right.