Object cards
NewProduct cards whose pictures are glass objects turning in 3D, drawn by one shared WebGL context.
object-cards/object-cards.tsxThe 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 product or feature tile on a landing page, where a turning object says more than a photo.
- Two to four cards side by side, one object each, alternating
toneso neighbours stand apart. - A card whose figures change with a time range:
onRangeChangeswaps the description, the object’s speed follows. - A link worth sharing: the arrow opens it, Copy Link and Share hand it on without leaving the page.
When not to use
- A long grid or a feed of many cards. Each one draws WebGL every frame; use a plain card with an image.
- Content that needs a real picture of the thing: a product photo, a screenshot, a person.
- Cards with more than a title and a short paragraph. The card keeps fixed proportions, so long text overflows.
- A range that filters real data on its own. Here it only sets the spin and reports the choice; wire the data yourself, or use a segmented control.
- Pages where motion must stay out of the way. Pass
still, or choose a static card.
You can add this component with the shadcn CLI or by hand:
Into components/object-cards of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/object-cards.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 { ObjectCard, ObjectCards } from './object-cards';// One card in your own grid.export function RoboticsCard() { return ( <ObjectCard object="knot" tone="light" title="Autonomous Robotics in Agriculture." description="Send field robots to read soil and crop health and plan each harvest." href="/robotics/agriculture" /> );}// Or the stage of two, without its demo.export function CardStage() { return <ObjectCards 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.
Below are variations of this component, each live, with its code.
Card grid
Three cards in a grid of their own, each with its own copy and starting range, so the objects turn at slow, medium and full speed side by side, all drawn by one shared WebGL context.
Large card
One card 440px wide, as in a featured spot: every size in it is a share of its width, so the picture and the type grow together.
Description by range
The description follows the range the reader picks, through onRangeChange: a day, a week or a month of a scouting fleet’s work, starting on 7D at medium speed.
Night colours
A light card held in its night colours by colorScheme: charcoal with a black picture whatever the page’s scheme, as on a dark band of a light page.
Two product cards on a stage, each with a deep blue glass object in its picture: a disc with an embossed mark and a torus knot. They turn on their own at the pace the card’s range sets. Drag either one to turn it, then let go and it coasts and settles back into its spin; Copy Link copies the card’s link and confirms it for a moment.
- One hidden WebGL context draws both objects. Each card has a plain 2D canvas, and as soon as an object is drawn its picture is copied onto that card’s canvas with drawImage, so there is one context, one program and one set of buffers however many cards there are.
- The glass is a fragment shader: deep navy on the light’s side, a glowing blue on the far side, and a narrow, crisp pale band along the outline. Sharp reflections of a small virtual studio read as gloss, and on the disc the outline of the mark tilts the surface along a thin bevel, so the mark looks embossed.
- Both shapes are built in code. The disc is a cross-section turned around its axis, with its corners rounded by Chaikin cutting; the knot is a (2, 5) torus knot with a tube swept along its curve.
- Each object turns on a clock whose speed follows the range (1D slow, 7D medium, 1M full) and eases to a new speed. A drag adds a turn about the axis across the drag; let go, and the object coasts while the added turn eases away. Frames run only while a card is on screen in a visible tab, and a canvas off screen gives its pixels back.
- Before WebGL starts, and in browsers without it, an SVG drawing stands in for each object, projected from the same shapes in the same starting pose, so nothing jumps when the canvas fades in.
- The stage and the cards are light-dark() tokens, so they follow the page’s color-scheme, or the colorScheme prop: at night the white card turns charcoal and the black one lifts its quiet text. The WebGL pictures read no colour from CSS and stay as they are.
| Prop | Type | Default | Description |
|---|---|---|---|
object | 'disc' | 'knot' | — | ObjectCard: the object in the picture. Required. |
tone | 'dark' | 'light' | 'dark' | ObjectCard: a black card with a white picture, or a white card (charcoal at night) with a black one. |
title | string | — | ObjectCard: the card’s title, on two lines. Required. |
description | string | — | ObjectCard: a short paragraph under the title. Required. |
href | string | — | ObjectCard: where the arrow leads, and the link Copy Link and Share give. Required. |
defaultRange | '1D' | '7D' | '1M' | '1M' | ObjectCard: the range chosen at first, which sets how fast the object turns. |
defaultCopied | boolean | false | ObjectCard: opens with Copy Link reading Copied, held until it is pressed again. |
still | boolean | false | ObjectCard: holds the object still, as under reduced motion; it turns only by hand or by key. |
onRangeChange | (range: Range) => void | — | ObjectCard: called with the range the reader chooses. |
ref | Ref<ObjectCardHandle> | — | ObjectCard: drives the card as a hand would; the demo uses it. |
autoplay | boolean | true | ObjectCards, the stage of two: while on screen and untouched, a ghost pointer turns the objects and changes their ranges. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the card, or for the stage’s root. |
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 { useEffect, useImperativeHandle, useRef, useState, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent, type Ref,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { ArrowIcon, CheckIcon, CopyIcon, ShareIcon } from './icons';import type { ObjectKind } from './renderer';import { StandIn } from './stand-in';import { useAutoplay } from './use-autoplay';import { useObjectView } from './use-object-view';import styles from './object-cards.module.css';/*/* Object cards (object-cards.tsx): two product cards side by side on a grey stage, one under the other in a narrow one; at night the stage is near-black, the dark card stays black and the light one turns charcoal, and the pictures (the objects and their grounds) keep their colours. Every size in a card is a multiple of --u, a 254th of the card's width, which the scene sets from the stage's width (a card on its own measures itself), so a card keeps its proportions at any size. Memory and layout. The scene is a layout boundary of a size known from the stage's width alone, so nothing that changes inside it lays out more than the scene. What changes while the demo plays stays off the page's tiles (a phone keeps them in buffers of several MB, and a tile painted again gets a second one): the objects turn on their canvases, the ranges and Copy Link each paint on a small layer of their own, and the pointer moves on its own layer without any layout (demo-pointer.tsx). */.stage { /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the stage inherits from the page, or the one the colorScheme prop sets (below). The stage and its edge. */ --surface: #c1c1c1; --line: rgb(0 0 0 / 0.06); position: relative; container-type: inline-size; overflow: hidden; overflow: clip; border-radius: 18px;'use client';import { useEffect, useRef, useState } from 'react';import { holdRenderer, releaseRenderer, type ObjectKind } from './renderer';import { spinAt } from './spin';import { axisAngle, identity, isIdentity, multiply, shrink, tidy, type Turn } from './turn';/* * One object in its picture: the canvas it is drawn on, its own spin and the turn a hand gives * it. Frame by frame, the clock of its spin moves on at the chosen speed and the object is drawn * as `spinAt(clock)`, turned further by what the reader added: a drag turns it about the axis * across the drag; let go, it keeps turning, slower and slower, while the added turn eases away * and the object settles back into its own spin. Only while it is on screen in a visible tab: * off screen the canvas gives its pixels back, and in a hidden tab nothing runs. Under reduced * motion, or with `hold` (the card's `still`), it does not spin on its own, and it stays where * the reader turns it. *//** * How long the spin takes to reach a new speed, a let-go object to slow down, and the turn the * reader added to ease away, in seconds (each the time to cover about two thirds of the way). */const EASE = 0.25;const COAST = 0.45;import { discMesh, knotMesh, DISC_FACE, type Mesh } from './shapes';import type { Turn } from './turn';/* * One WebGL context draws every object on the page. It renders on a canvas of its own, which is * never shown; each card has a plain 2D canvas, and as soon as an object is drawn its picture is * copied there with drawImage. One context, one set of buffers and one program serve any number * of cards, where a context per card would hold its own of each, multisampled buffers included. * * The glass is a fragment shader, lit in view space: * - the body goes from a deep blue on the key light's side to a glowing one on the far side, * where the light that went through gathers, and is deepest where it faces the eye; * - toward the outline the light caught inside glows, and a narrow band along the outline turns * pale, as glass reflects more at grazing angles (dark under the disc, as the edge of a glass * on a white table is); * - the highlights are reflections of a studio (a band of light round the horizon, a light * overhead, two soft boxes), with sharp edges, so they read as gloss; * - on the disc's top face, a mark is embossed: its outline, a distance field, bends the * surface's normal along a narrow bevel, which catches the light. The face is a little * hollow, so its reflections move as the disc wobbles. */export type ObjectKind = 'disc' | 'knot';/* * The two objects, as meshes built once: a glass disc turned on a lathe, and a torus knot swept * along its curve. Each is a list of triangles over vertices that carry a position and a normal, * interleaved (x, y, z, nx, ny, nz). Both fit in a sphere of radius 1 around the origin. */export type Mesh = { /** Six floats per vertex: its position, then its normal. */ vertices: Float32Array; /** Three vertex numbers per triangle. */ indices: Uint16Array;};export type Vec3 = [number, number, number];/** * The disc's cross-section, in units of its radius, from the middle of its bottom face round its * edge to the middle of its top face, as (distance from the axis, height): a flat bottom, a * straight side, a flat-topped lip, and a top face sunk a little inside the lip, like a dish. * The corners are rounded when the mesh is built. */export const DISC_SECTION: readonly [number, number][] = [ [0, -0.21], [0.9, -0.21],import type { ObjectKind } from './renderer';import { axisAngle, chain, type Turn } from './turn';/* * How each object turns on its own, as a function of time: where it is after `time` seconds at * the normal speed. The knot turns about an axis that leans to the left, showing its narrow side * and its five-fold face in turn, while it also turns slowly about its own axis. The disc spins * about its own axis, leaning back by about 40 degrees, and wobbles a little once a turn, so its * face always shows, its edge catches the light below it and its reflections move. *//** The disc's spin, and its wobble with it: a full turn every 3.7 seconds, in radians per second. */const WOBBLE = (2 * Math.PI) / 3.7;export function spinAt(kind: ObjectKind, time: number): Turn { if (kind === 'knot') { return chain( axisAngle(0, 0, 1, 0.29), axisAngle(0, 1, 0, 0.292 * time - 2.32), axisAngle(1, 0, 0, 0.17), axisAngle(0, 0, 1, 0.06 - 0.13 * time), ); } // The disc's axis is its y: a quarter turn about x brings its top face to the viewer, less/* * Rotations as 3 × 3 matrices, in the column-major order WebGL takes them: element (row r, * column c) sits at c * 3 + r. Enough to turn an object about any axis and to stack turns. */export type Turn = Float32Array;export const identity = (): Turn => new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);/** A turn by `angle` radians about the axis (x, y, z), which need not be of length 1. */export function axisAngle(x: number, y: number, z: number, angle: number): Turn { const length = Math.hypot(x, y, z); if (!length || !angle) return identity(); const [a, b, c] = [x / length, y / length, z / length]; const cos = Math.cos(angle); const sin = Math.sin(angle); const k = 1 - cos; return new Float32Array([ cos + a * a * k, b * a * k + c * sin, c * a * k - b * sin, a * b * k - c * sin, cos + b * b * k, c * b * k + a * sin,import { memo, useId } from 'react';import { LOOKS, project, type ObjectKind } from './renderer';import { DISC_FACE, DISC_LIP, DISC_SECTION, KNOT, KNOT_TUBE, knotPoint } from './shapes';import { spinAt } from './spin';/* * The objects as plain drawings, for the first frame (the server's, before WebGL starts) and for * browsers without WebGL. Each is projected from the same shapes, turned as the renderer turns * them at the start, so the picture does not jump when the canvas takes over: * - the disc is a few rings of its section (the outline, the lip, the well inside it, the face), * filled with gradients, and the mark's outline; * - the knot is its curve cut into short pieces, painted from the farthest to the nearest, each * a thick stroke with a pale rim, a deep core and a highlight along it, so the near tube covers * the far one where they cross. *//** The picture's size, in the units of its view box: the media area's proportions. */const PICTURE = { width: 245, height: 183 } as const;type Point = { x: number; y: number; depth: number };const where = (kind: ObjectKind, point: readonly [number, number, number]): Point => project(kind, spinAt(kind, 0), point, PICTURE.width, PICTURE.height);/* * The cards' small icons, drawn on a 12 × 12 grid with strokes in the text's colour. They are * decorative: each sits beside a text or inside a labelled control. */const STROKE = { fill: 'none', stroke: 'currentColor', strokeWidth: 1, strokeLinecap: 'round', strokeLinejoin: 'round',} as const;/** An arrow up and to the right: open the item. */export function ArrowIcon() { return ( <svg viewBox="0 0 12 12" aria-hidden="true"> <path {...STROKE} d="M3.5 8.5 8.5 3.5M4.6 3.5h3.9v3.9" /> </svg> );}/** Two sheets, one over the other: copy. The front one is filled with the button's colour. */export function CopyIcon() {'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, * glide the pointer onto a control and press it, or grab an object and drag it, calling the * component's own handlers as a hand would. Stopping the demo (the reader takes over, or it * scrolls away) ends the step in progress at once: its promise rejects, which ends the script * right there, and the pointer fades out. * * Nothing the pointer does while the demo plays needs a layout, because one here in the same * frame as one in the cards would make the browser lay out the whole page (see the note at the * top of the stylesheet). So it moves by animations the compositor plays (a drag too: the dragged * object follows that animation's clock); the press scales a box that keeps a transform at rest, * since a box that gains or loses one is laid out again; and the arrow and the hand are HTML boxes * that swap by their visibility (a change of style inside an SVG lays the SVG out again). */export type Point = { x: number; y: number };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>;'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( ([entry]) => { onScreen = entry.isIntersecting;- Each picture is a focusable image with a name (“A blue glass knot, turning. Drag it, or use the arrow keys, to turn it.”), and the arrow keys turn the object. On touch, a sideways drag turns it and a vertical swipe still scrolls the page.
- The ranges are a radio group named “Turning speed”: the arrow keys, Home and End move the choice and the focus together, and each option’s name keeps its visible label (“1D, slow”).
- Range changes (“7D: turning medium”) and copying (“Link copied”) are announced in a polite live region; the demo’s own actions stay silent. Every control has a visible focus outline.
- Under reduced motion the objects don’t spin on their own, a drag stops without coasting, and the demo never plays.