Sticker lens
NewA coming-soon page with a pile of stickers to throw around and a glass ball that magnifies and bends everything under it.
sticker-lens/sticker-lens.tsxShop
Coming soon
Meanwhile, click, drag and explore ↓
Drag it, or move it with the arrow keys.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A holding page while a shop or a launch is not ready yet, where the page itself is the thing to play with until it opens.
- A brand with physical goods (stickers, prints, merch) that wants the reader to pick them up, stack them and throw them around.
- Showing off a tactile, glassy material: the lens magnifies and bends the page’s own words, not a stock picture.
- A playground for tuning a refraction effect, with presets to compare and the values to copy as JSON.
When not to use
- A page whose job is to collect an email or sell something now. Use a plain hero with a form and one clear call to action.
- Magnifying a product photo to inspect its detail. Use an image zoom with a loupe on the picture alone.
- Arranging items that must keep their order or be saved. Use a sortable list or a board with drag handles.
- Low-power screens or pages that must stay quiet: it paints the page into a canvas and runs a WebGL shader. Use a static hero.
- A frosted surface behind a menu or a sheet. Use backdrop-filter on that surface.
You can add this component with the shadcn CLI or by hand:
Into components/sticker-lens of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/sticker-lens.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { StickerLens } from './sticker-lens';export function ShopPage() { return ( <StickerLens eyebrow="Shop" title="Coming soon" lens={{ magnification: 2, oblateness: 0.8 }} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A shop that has not opened yet, with a pile of black-and-white stickers to pick up, stack and fling, and a glass ball lying on top. The ball magnifies what is beneath it, the page’s own words included, squeezes the world in at its rim and splits colours along its steep edge. A settings panel tunes its radius, magnification, index of refraction, aberration, thickness, bevel, oblateness, refraction and frost, in light and dark.
- The glass needs a picture of the page. The HTML-in-canvas proposal (a canvas with layoutsubtree and drawElementImage) would hand it the live DOM but is still behind a flag, and an SVG displacement filter as a backdrop-filter only works in Chromium and cannot follow a parametric dome. So the page is painted into a canvas from what it is made of: its boxes and words from their computed styles, on their real baselines, then the stickers, in stacking order, as the very SVG images the page shows.
- That canvas becomes a WebGL texture, and a fragment shader draws the lens. Each pixel reads the scene at the centre plus its offset over the magnification, plus an outward bend from the slope of a superellipse dome: the higher the oblateness, the flatter the middle and the steeper the rim, so the edge squeezes in what lies beyond the disc.
- The bend grows with the thickness, with 1 − 1/ior (Snell’s law for small angles) and with the refraction displacement, and reaches full strength past the bevel start. Red and blue are read a few px either side of green along the slope (the aberration), frost averages nine reads, and a fresnel edge, a thin ring of light and a soft glint make it read as glass by day and by night.
- The scene is repainted only when something under the glass changes: a sticker moves, the scheme or the size changes, fonts arrive. Moving the lens only redraws the lens. Without WebGL, a 2D canvas draws a plain magnifier with the same rim.
- Every sticker is an original SVG drawing in four inks (ink, paper, a grey and a pale grey) read from the colour tokens, so at night ink and paper swap and a black label turns white.
- Picking a sticker up raises it to the top of the pile, lifts it 5% with a deeper shadow and tilts it with the drag’s speed; letting go throws it with the release speed, slowed by friction and bounced off the page’s edges. The lens glides after the pointer that drags it, and always lies on top.
- Positions are kept in the design’s px around the pile’s middle and scaled to the stage, so the arrangement survives any size; under 640 px the stickers lie in a taller pile.
- While on screen and untouched, a demo pointer rolls the lens over the pile and the title, slides a sticker under it, opens the panel and scrubs the oblateness, then tidies up. Any real input hands it over.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | 'Coming soon' | The big line in the middle of the page. |
eyebrow | string | 'Shop' | The small capitals above it. |
lead | string | 'Meanwhile, click, drag and explore' | The line under it, followed by a down arrow. |
brand | string | 'Studio Kite' | The name at the top left. |
lens | Partial<LensParams> | — | The lens’s starting tuning: radius (px), magnification, ior, aberration, thickness, bevelStart, oblateness, refraction and frost. Anything left out keeps its default (180, 1.75, 1.52, 0.01, 0.9, 0.9, 0.65, 0.6, 0). |
defaultLensOpen | boolean | true | The lens starts on the page (the panel’s Open switch). |
defaultPanelOpen | boolean | false | The settings panel starts open. |
follow | boolean | false | The lens follows a mouse or pen around the page, instead of waiting to be dragged. |
onLensChange | (params: LensParams) => void | — | Called with the tuning whenever the panel changes it. |
autoplay | boolean | true | While on screen and untouched, a demo pointer plays with the lens, a sticker and the panel. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark colours, or the reader’s; without it, the page’s color-scheme. The page’s own header buttons change it too. |
className | string | — | Classes for the page’s box, which takes its width from outside. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { createLensRenderer, type LensParams, type LensRenderer } from './lens-renderer';import { Panel, ROWS, SlidersIcon, type Version } from './panel';import { paintScene, type ScenePiece } from './scene';import { LENS_HOME, PILE, STICKERS, toDataUrl, type Inks } from './stickers';import { useAutoplay } from './use-autoplay';import styles from './sticker-lens.module.css';/* * Sticker lens: a "coming soon" shop page with a pile of black-and-white stickers to pick up and * throw around, and a glass ball lying on the pile that magnifies and bends whatever is under it,// Copyright (c) 2026 Bruno Delorence. MIT License./* * The lens itself: a small canvas the size of the lens that draws, for every pixel of the disc, * the part of the scene its glass would bend towards it. * * The scene is a bitmap (scene.ts paints the page's words and the stickers into a canvas), here * a WebGL texture. Each pixel of the disc reads it at * * centre + offset / magnification + outward bend * * where the bend comes from the slope of the glass at that point. The glass is a superellipse * dome: the higher the oblateness, the flatter its middle and the steeper its rim, so the middle * reads as a clean magnifier and the edge squeezes in what lies beyond the disc. The bend grows * with the thickness, the index of refraction (1 - 1/ior, as Snell's law gives for small angles) * and the refraction displacement, and only reaches full strength past the bevel start. Red, * green and blue are read at three slightly different bends (the aberration), so edges fringe * with colour where the glass is steep. Frost averages eight reads around each one. A rim of * light, a soft specular glint and a darker fresnel edge make it read as glass in either scheme. * * Without WebGL, a 2D canvas fallback draws the magnified scene clipped to the disc with the * same rim, without the refraction. */// Copyright (c) 2026 Bruno Delorence. MIT License./* * The picture under the lens: the page painted into a canvas, which the lens then bends. * * Browsers cannot yet hand a canvas a picture of live HTML (the HTML-in-canvas proposal, a * canvas with `layoutsubtree` and `drawElementImage`, is still behind a flag), so the page is * painted again from what it is made of: the boxes marked data-lens-box (their background, * border and radius as computed), the words marked data-lens-text (in their computed font, * colour and spacing, on their own baseline), then the stickers, in their stacking order, as the * same SVG images the page shows. It is repainted only when something under the lens changes. */export type ScenePiece = { image: HTMLImageElement; /** Middle, size (px of the stage) and turn (degrees). */ x: number; y: number; w: number; h: number; r: number; /** How far it is lifted off the page while dragged, 0 to 1. */ lift: number;};// Copyright (c) 2026 Bruno Delorence. MIT License./* * The sticker pile: twelve original drawings, each an SVG document written as a string, so the * same picture can be the <img> the reader drags and the bitmap the lens refracts (the lens * draws the scene into a canvas, and an <img> of an SVG draws into a canvas at any size, crisp). * * Every drawing is in four inks only, handed in per colour scheme: `ink` (the black of the * print), `paper` (the white of the sticker), `mid` (a printed grey) and `soft` (a pale grey). * At night ink and paper swap, so a black label turns white and the other way round. * * Text inside an SVG image cannot reach the page's web fonts, so the drawings name system faces * (a heavy grotesque, a monospace, a script) with generic fallbacks. */export type Inks = { ink: string; paper: string; mid: string; soft: string };export type StickerArt = { id: string; /** What the sticker shows, for its accessible name. */ label: string; /** Its size at the design's scale, in px. */ w: number; h: number;// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, KeyboardEvent, Ref } from 'react';import type { LensParams } from './lens-renderer';import styles from './sticker-lens.module.css';/* * The settings panel: the lens's tuning, one row per value. Each row is a scrub bar: a native * range input stretched invisibly over the whole row, so pointer, touch, keyboard and screen * readers all work as they do on any slider, while the row draws its own fill, ticks and value. * Versions are presets; the plus button keeps the current tuning as a new one, and the copy * button puts it on the clipboard as JSON. */export type Version = { name: string; params: LensParams };export const ROWS: { key: keyof LensParams; label: string; min: number; max: number; step: number;// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver(/* Sticker lens (sticker-lens.tsx): a quiet shop page, pale grey by day and black at night, with the sticker pile in its middle, the glass lens on top, a nav pill at the bottom and the settings panel at the top right. Sizes are the design's px; the pile scales itself to the stage from script. Colours are the tokens below, light-dark(light, dark) where the two differ: the page takes the color-scheme it inherits, or the one its colorScheme prop or its own header buttons set (data-color-scheme). The sticker inks are read from script and drawn into the SVG art. Memory. The stage is one layout boundary sized from outside; stickers and the lens move by transforms on layers of their own, so dragging repaints nothing else. */.root { --page: #f6f6f6; --text: #1b1b1b; --title: #4b4b4b; --muted: #4f4f4f; --chip: #fff; --chip-line: rgb(0 0 0 / 0.05); --chip-active: #eef0ee; --chip-shadow: rgb(0 0 0 / 0.05); --solid: #1b1b1b; --solid-ink: #fff; --sticker-ink: #121212;- Every sticker and the lens is a real button with a name (what the sticker shows) and a hint: Tab reaches each one, and the arrow keys move it (Shift for bigger steps), raising a sticker to the top.
- The settings panel is a labelled dialog: the toolbar button says whether it is expanded, Escape closes it and gives the focus back, and every row is a native range input, so keyboards and screen readers read and change its value as on any slider.
- The Open switch and the colour-scheme buttons are toggle buttons with aria-pressed; the nav marks the current page with aria-current.
- Saving or loading a version, copying the settings, switching the lens and resetting the pile are announced in a polite live region.
- The lens canvas, the sticker art and the demo pointer are hidden from assistive technology; the words the lens bends stay real text.
- With reduced motion there is no demo, a thrown sticker stops where it is let go and the lens jumps to the pointer instead of gliding.