Character picker
Pick a 3D character, change its colour, and watch its eyes follow your pointer; the pick flies up into the avatar.
character-picker/character-picker.tsxMoss
Your helper for notes, mail and plans
You can add this component with the shadcn CLI or by hand:
Into components/character-picker of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/character-picker.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:
- Copy the media files
To the same paths under your project’s public folder:
/experiments/character-picker/robot-3d-23d38062f340.webp/experiments/character-picker/gift-3d-c582bec17c61.webp/experiments/character-picker/heart-3d-6067f62af297.webp/experiments/character-picker/star-3d-3b6683298df7.webp/experiments/character-picker/bubble-3d-c0930c190908.webp/experiments/character-picker/calendar-3d-e99464209cbf.webp/experiments/character-picker/bulb-3d-d55f5fd4d1ea.webp/experiments/character-picker/envelope-3d-24b90a2a495f.webp/experiments/character-picker/gear-3d-2d3e7cda2aa4.webp/experiments/character-picker/folder-3d-449b82b9f073.webp/experiments/character-picker/book-3d-1f63dd5d23b9.webp/experiments/character-picker/chart-3d-122015d97523.webp/experiments/character-picker/pin-3d-ca69d6bcffe4.webp/experiments/character-picker/padlock-3d-0c76042a5ec9.webp/experiments/character-picker/flame-3d-4e0b9be3b98e.webp/experiments/character-picker/browser-3d-759d64556239.webp/experiments/character-picker/briefcase-3d-38f9b8e338fd.webp/experiments/character-picker/clipboard-3d-022a141e3fff.webp/experiments/character-picker/bolt-3d-e4c518eb54fa.webp/experiments/character-picker/ball-3d-39752beba86c.webp
- 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
'use client';import { useState } from 'react';import { CharacterPicker, type Avatar } from './character-picker';export default function AssistantSettings() { const [avatar, setAvatar] = useState<Avatar | null>(null); return ( <> <CharacterPicker name="Moss" onChange={setAvatar} autoplay={false} /> <p>{avatar ? `${avatar.id}, ${avatar.colour}` : 'Pick an avatar'}</p> </> );}Choose an avatar from twenty softly lit 3D object characters, from a robot to a padlock, or the same objects as line icons. Change the selected avatar with seven visible colour swatches, or open a tile’s palette to compare the full character in every colour. The pupils follow your pointer, the bodies turn slightly, and individual characters blink. A pick flies up into the avatar and lands with a squash. The demo stops after your first interaction.
- The pupils follow the pointer without a React render. One listener writes the pointer’s place to two registered custom properties; each pair of pupils computes its direction in CSS with atan2() and hypot(). The white eyeballs stay in place while the body turns gently towards the pointer.
- Each body is a generated transparent 3D render with consistent soft lighting and material. Calibrated hue, saturation and brightness filters supply the seven colours while preserving the render’s texture and shadows. The eyes, cheeks and smile are live SVG layers above the image, outside the colour filter.
- The selected avatar has a visible, keyboard-accessible palette of seven swatches. One additional palette chip and colour strip serve the whole grid; the strip opens from its tile and shows the complete character in each colour.
- A pick is FLIP: the new avatar is drawn in its slot and measured, then played from the picked drawing’s box along an arc with the Web Animations API, and it lands with a squash and a blink.
- The demo’s pointer moves the same two properties, so the pupils follow it like a real pointer. A real press or keyboard action stops the demo until the preview is restarted. Blinks pick one or two characters at a time; blinks and the avatar’s gentle bob pause off screen, in a hidden tab or under reduced motion.
- Every interface colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. The characters, the palette and the colour wheel are art and keep their colours; at night the avatar’s disc deepens to a dark tint of its colour, and line icons take the colour’s base tone.
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | 'Moss' | The assistant’s name, under its avatar. |
tagline | string | 'Your helper for notes, mail and plans' | A short line under the name. |
defaultAvatar | Avatar | { kind: 'character', id: 'robot', colour: 'blue' } | The avatar at first: a character or an icon (kind), which object (id) and its colour. |
onChange | (avatar: Avatar) => void | — | Called with the new avatar when one is picked. |
autoplay | boolean | true | While on screen and untouched, a demo pointer wanders over the grid and picks avatars. A real press or keyboard action stops it until the component is remounted. |
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 root, a stage of its own. |
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, useId, useLayoutEffect, useRef, useState, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent,} from 'react';import { CHARACTERS, Character, CharacterDefs, DefsId, Icon, type CharacterId } from './characters';import { GhostPointer, runDemo } from './ghost-pointer';import { COLOURS, colourOf, type ColourId } from './palette';import { spring } from './spring';import { useAutoplay } from './use-autoplay';import styles from './character-picker.module.css';/* * Character picker: choose an assistant's avatar from a grid of rendered 3D object characters, or the/* Character picker (character-picker.tsx): a stage with the assistant's avatar on top and the picker under it. The pointer's place, in the stage's px, is in --cp-px and --cp-py, and every pair of eyes turns towards it. They are registered (prefixed, as @property names are global) so that the demo's pointer can glide them, and --cp-look so that the eyes ease in and out. Colours are the tokens below, light, then light-dark(light, dark) pairs: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The pairs sit in @supports, which a build that rewrites light-dark() for older browsers leaves as written; without light-dark(), the light colours stay. The characters keep their own colours in both. */@property --cp-px { syntax: '<length>'; inherits: true; initial-value: 0px;}@property --cp-py { syntax: '<length>'; inherits: true; initial-value: 0px;}@property --cp-look { syntax: '<number>'; inherits: true;'use client';import { createContext, useContext, type CSSProperties, type ReactNode } from 'react';import { materialFilterOf, type ColourId } from './palette';import { CHARACTER_RENDERS } from './render-assets';import { gear, polygon, rect, star } from './shapes';import styles from './character-picker.module.css';/* * Twenty 3D object cutouts, generated with a blank face and a consistent mint material. * A calibrated CSS material filter supplies seven colours. Native SVG eyes, cheeks and smiles * stay above the render, so pupils track the pointer and blinks work in every colour. * The separate line-icon view keeps the same objects and public avatar ids. */export type CharacterId = | 'robot' | 'gift' | 'heart' | 'star' | 'bubble' | 'calendar' | 'bulb' | 'envelope'// Copyright (c) 2026 Bruno Delorence. MIT License./** Transparent 3D bodies; live facial features are drawn by Character. */export const CHARACTER_RENDERS = { robot: '/experiments/character-picker/robot-3d-23d38062f340.webp', gift: '/experiments/character-picker/gift-3d-c582bec17c61.webp', heart: '/experiments/character-picker/heart-3d-6067f62af297.webp', star: '/experiments/character-picker/star-3d-3b6683298df7.webp', bubble: '/experiments/character-picker/bubble-3d-c0930c190908.webp', calendar: '/experiments/character-picker/calendar-3d-e99464209cbf.webp', bulb: '/experiments/character-picker/bulb-3d-d55f5fd4d1ea.webp', envelope: '/experiments/character-picker/envelope-3d-24b90a2a495f.webp', gear: '/experiments/character-picker/gear-3d-2d3e7cda2aa4.webp', folder: '/experiments/character-picker/folder-3d-449b82b9f073.webp', book: '/experiments/character-picker/book-3d-1f63dd5d23b9.webp', chart: '/experiments/character-picker/chart-3d-122015d97523.webp', pin: '/experiments/character-picker/pin-3d-ca69d6bcffe4.webp', padlock: '/experiments/character-picker/padlock-3d-0c76042a5ec9.webp', flame: '/experiments/character-picker/flame-3d-4e0b9be3b98e.webp', browser: '/experiments/character-picker/browser-3d-759d64556239.webp', briefcase: '/experiments/character-picker/briefcase-3d-38f9b8e338fd.webp', clipboard: '/experiments/character-picker/clipboard-3d-022a141e3fff.webp', bolt: '/experiments/character-picker/bolt-3d-e4c518eb54fa.webp', ball: '/experiments/character-picker/ball-3d-39752beba86c.webp',/* * Path helpers for the drawings (characters.tsx): rounded rectangles, ellipses, and polygons, stars * and gears with rounded corners, as SVG path data. Every part of a drawing is one path, so the * same path can be painted twice: its flat colour, then the shared light and shade over it. *//** Two decimals are plenty in a 64-unit drawing. */const n = (value: number) => Math.round(value * 100) / 100;/** A rectangle with rounded corners: `top` for the two upper ones, `bottom` for the two lower. */export function rect(x: number, y: number, w: number, h: number, top: number, bottom = top) { const arc = (r: number, toX: number, toY: number) => r ? `A${r} ${r} 0 0 1 ${n(toX)} ${n(toY)}` : `L${n(toX)} ${n(toY)}`; return [ `M${n(x + top)} ${n(y)}H${n(x + w - top)}`, arc(top, x + w, y + top), `V${n(y + h - bottom)}`, arc(bottom, x + w - bottom, y + h), `H${n(x + bottom)}`, arc(bottom, x, y + h - bottom), `V${n(y + top)}`, arc(top, x + top, y), 'Z', ].join('');/* * The colours a character comes in. Each has a base for its body, a deeper tone for the parts in * shade (a ribbon, a tab, a handle) and a soft tint for the light parts (a screen, an inner flame, * the avatar's backdrop by day; at night the CSS deepens the backdrop from the base). A drawing * uses a calibrated material filter to recolour the mint 3D render without flattening its light * and shade. The live eyes are separate and keep their original colours. */export type ColourId = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink';export type Colour = { id: ColourId; /** As read out: "Folder, blue". */ name: string; base: string; deep: string; soft: string;};export const COLOURS: readonly Colour[] = [ { id: 'red', name: 'red', base: '#ff6a61', deep: '#e1443e', soft: '#ffd4d0' }, { id: 'orange', name: 'orange', base: '#ff9a3c', deep: '#e87316', soft: '#ffe2c4' }, { id: 'yellow', name: 'yellow', base: '#ffca38', deep: '#e9a113', soft: '#fff0bf' }, { id: 'green', name: 'green', base: '#55cb7c', deep: '#2ba257', soft: '#cff2da' },'use client';import type { Ref } from 'react';import styles from './character-picker.module.css';/* * The demo's ghost pointer, and the timeline that moves it. The pointer is drawn at * (--cp-px, --cp-py), the two properties the reader's pointer writes too, so the characters' eyes * follow it like a real one. A glide is a transition of those registered properties, one per axis * with its own easing, which bends the path into a gentle arc; nothing runs between glides. * * A demo is a script of awaited steps: wait, glide onto a control, press it and call the * component's own handler, as a click 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. */export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides onto `target`: its middle, or a point given as shares of its size. */ moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>; /** Presses `target` and lets go; `action` runs as the pointer goes down. */ press: (target: Element | null | undefined, action: () => void) => Promise<void>;/* * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first, * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws * any curve through a list of points, so the spring's motion is sampled once, and the same curve * serves CSS transitions and the Web Animations API. */export type Spring = { /** The curve, as a CSS `linear()` easing. */ easing: string; /** How long it takes to settle, in ms: the duration to play the curve over. */ duration: number;};/** Points sampled along the curve: plenty for a smooth motion. */const SAMPLES = 40;/** * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it * settles without overshooting; at 0.3 it overshoots by about 5% and comes back. */export function spring(bounce: number, response: number): Spring { // The damping ratio (1: no overshoot) and the natural frequency, in radians per second. const damping = 1 - bounce;'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;- The grid is a listbox: the arrow keys move through the characters (up and down by rows), Home and End jump to the ends, and Enter or Space picks. Each option is named with its colour, as in “Folder, blue”.
- The palette chip is a button (“Colours for Folder”, with aria-expanded) that opens a labelled listbox of seven colours; Escape closes it and returns focus to the chip.
- The visible colour swatches are a radiogroup labelled for the selected avatar. Arrow keys change colour, Home and End jump to the ends, and the selected swatch is the group’s one tab stop.
- Each pick is announced politely (“Avatar set to Folder, blue.”). The avatar is an image named after it, the 3D characters and Icons switch is a radiogroup, and the renders, eyes and blinks are hidden from screen readers.
- Under reduced motion nothing flies or blinks, the eyes keep still, and the demo does not run.