Glow icon
A living app icon: a little ghost glowing in a glassy frame watches your pointer, blinks and jumps when you press it, shown on purple, on a phone and on light.
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
- Presenting an app icon on a landing page or a launch post, where the icon is the hero and deserves to feel alive.
- Showing one icon in context: on brand colour, on a phone’s home screen and on light, with one switch between them.
- A mascot that reacts to the visitor: it follows the pointer with its eyes and jumps when pressed.
- A single, large instance with room around it: the scene is 16:10 on a wide page and square on a phone.
When not to use
- As an actual app icon or favicon: those are flat images. Export the artwork as a PNG or SVG instead.
- In lists, grids or toolbars with many icons: one living icon draws the eye, a dozen compete. Use static images.
- As a button that does something important: pressing it only plays a jump. Use a real button with a label.
- Next to content that needs reading: the float, the blinks and the demo pull attention away from it.
You can add this component with the shadcn CLI or by hand:
Into components/glow-icon of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/glow-icon.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
'use client';import { useState } from 'react';import { GlowIcon } from './glow-icon';export default function AppIconHero() { const [presses, setPresses] = useState(0); return ( <> <GlowIcon defaultSetting="phone" onPress={() => setPresses((n) => n + 1)} autoplay={false} /> <p>Pressed {presses} times</p> </> );}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.
An app icon with a ghost living in it: it floats in its own light, blinks now and then and follows your pointer with its eyes while the glass tilts towards it. Press it and the icon squishes and the ghost, startled, jumps. A switch shows it on a purple backdrop, on the home screen of a phone held at an angle, where it flies into its slot among plain icons, and on light.
- The icon is SVG and CSS with no blur filters: a deep violet rim around a lavender face, and a ghost whose body is one radial gradient, violet light at its heart fading to milky white at the edge. A rim of light runs down its lit side, with a halo around it and a pool of light below.
- One pointer listener writes the pointer’s place for the eyes, as registered properties read with atan2() and hypot(), and the icon’s tilt, from -1 to 1 on each axis. The 3D turn, the sliding specular spot and the ghost’s lean are CSS transitions the compositor plays.
- The icon is 3D, with will-change, only while the pointer or the demo is on the stage. Its glow on the backdrop sits beside it rather than inside it, so the tilted layer stays the icon’s size.
- Pressing squishes the icon with the Web Animations API and springs it back on a linear() spring from wherever it had got to. The ghost’s eyes go wide while it is held, and it jumps when it is let go.
- A setting is three registered backdrop colours, so switching cross-fades the backdrop. On the phone, the icon flies into its slot with a translate, a scale and a rotate worked out from the phone drawing’s numbers, so its layer keeps its size.
- Only the frame around the picture, the stage and the switch, takes light-dark() tokens and follows the page’s color-scheme, or the colorScheme prop; the scene itself, icon, glow and backdrops, stays as it is.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultSetting | 'purple' | 'phone' | 'light' | 'purple' | The setting at first: on a purple backdrop, on the home screen of a phone, or on light. |
defaultMood | 'calm' | 'startled' | — | The ghost’s mood at first: 'startled' draws the icon held down, squished and wide-eyed, until the reader’s first press. Setting it turns the demo off. |
gaze | { x: number; y: number } | — | Holds the eyes and the tilt on a point of the stage, in shares of its width and height (0 to 1); the reader’s pointer still takes over while on it. Setting it turns the demo off. |
onPress | () => void | — | Called when the reader presses the icon. |
onSettingChange | (setting: 'purple' | 'phone' | 'light') => void | — | Called when the reader picks a setting on the switch. |
autoplay | boolean | true | While on screen and untouched, an invisible point wanders (the eyes follow it and the icon tilts towards it), presses the icon once, and changes the setting. |
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, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent,} from 'react';import { IconArt, type Mood } from './icon-art';import { Phone } from './phone';import { SETTINGS, type SettingId } from './settings';import { easingOf, spring } from './spring';import { runTimeline } from './timeline';import { useAutoplay } from './use-autoplay';import styles from './glow-icon.module.css';/* * Glow icon: a living app icon. A little ghost floats in a glassy rounded square, glowing from * within; it looks at the pointer, blinks now and then, and jumps when the icon is pressed. A/* Glow icon (glow-icon.tsx): a light stage with the scene, the setting the icon is shown in, and the switch under it; at night the stage and the switch go dark, and the scene (the icon, its glow, the phone and the three backdrops) keeps its colours. The backdrop's colours are registered custom properties, so a change of setting cross-fades it; the pointer's place (--gi-px, --gi-py) and --gi-look are registered so that the demo can glide them and the eyes ease back. Prefixed, as @property names are global. */@property --gi-top { syntax: '<color>'; inherits: true; initial-value: #7d43f0;}@property --gi-middle { syntax: '<color>'; inherits: true; initial-value: #6a2fdc;}@property --gi-bottom { syntax: '<color>'; inherits: true; initial-value: #5b24c8;}@property --gi-px { syntax: '<length>';'use client';import { useId, type Ref } from 'react';import styles from './glow-icon.module.css';/* * The app icon: a rounded square with a deep violet rim around a lavender face, and a little ghost * floating in it, glowing from within. All of it is SVG and CSS, and every glow is a radial * gradient: no blur filters. * * - The ghost is milky and translucent: its body is one radial gradient, violet light at its * heart fading to white at the edge, with a rim of light on the side the light comes from and a * glint on its head. Its two glassy eyes are a gradient of dark violet with two glints. * - Around it, a soft halo on the face and a pool of light on the floor, which breathes as it * floats. * - The icon is built as a few layers: the face, the ghost (which floats, and leans and looks), * and the glass on top: a fixed sheen, and a specular spot that slides as the icon tilts. *//** How the ghost feels: calm, or startled while the icon is held down. */export type Mood = 'calm' | 'startled';export function IconArt({ label,'use client';import { useId } from 'react';import styles from './glow-icon.module.css';/* * The phone of the "Phone" setting: the top corner of a phone held at an angle, with a steel edge * and its side buttons, a dark screen, and a grid of plain, dimmed placeholder icons. The first * slot is left empty: the icon flies there. Drawn in phone units (1000 wide); the stylesheet * places and turns it, and the icon's slot is worked out from the same numbers. *//** An icon's size on the screen, the step from one to the next, and the first one's corner. */const ICON = 172;const STEP = { x: 248, y: 268 };const FIRST = { x: 106, y: 160 };/** The placeholders around the icon's slot: column, row, and a dim tint. */const APPS = [ { column: 1, row: 0, tint: 'rgb(84 190 110 / 0.3)' }, { column: 2, row: 0, tint: 'rgb(255 255 255 / 0.1)' }, { column: 3, row: 0, tint: 'rgb(255 168 76 / 0.22)' }, { column: 0, row: 1, tint: 'rgb(70 140 255 / 0.28)' }, { column: 1, row: 1, tint: 'rgb(255 255 255 / 0.12)' },/* * The settings the icon is shown in: a vivid purple backdrop, the home screen of a phone held at * an angle, and a light backdrop. Each sets the backdrop's three colours, which the stylesheet * transitions as registered custom properties, so a change of setting cross-fades the backdrop. */export type SettingId = 'purple' | 'phone' | 'light';export type Setting = { id: SettingId; /** Its button's label. */ label: string; /** Where it puts the icon, as read out: "On a purple backdrop." */ place: string; /** The backdrop, from top to bottom. */ top: string; middle: string; bottom: string;};export const SETTINGS: readonly Setting[] = [ { id: 'purple', label: 'Purple',/* * The demo's timeline: a script of awaited waits. Stopping it (the reader takes over, or the icon * scrolls away) rejects the wait in progress, which ends the script right there; nothing of it * runs afterwards. *//** What a stopped timeline's pending wait rejects with. */class Stopped extends Error {}/** Plays `script`; returns the function that stops it. */export function runTimeline(script: (wait: (ms: number) => Promise<void>) => Promise<void>) { let alive = true; const timers = new Set<number>(); const pending = new Set<() => void>(); const wait = (ms: number) => new Promise<void>((resolve, reject) => { if (!alive) return reject(new Stopped()); const cancel = () => reject(new Stopped()); const timer = window.setTimeout(() => { timers.delete(timer); pending.delete(cancel); resolve(); }, ms);/* * 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 icon is an image whose name follows the setting (“App icon: a little ghost glowing in a violet frame, on a purple backdrop”); the phone and its placeholder icons are hidden from screen readers.
- Pressing is a real button, “Press the icon”: hold Space or Enter for the squish and let go for the jump, and “Pressed. The ghost jumps.” is announced.
- The setting switch is a radiogroup labelled “Setting”: the arrow keys move the choice, and each change is announced (“On the home screen of a phone.”).
- Under reduced motion the ghost does not float or blink, the icon does not tilt, the eyes keep still, settings change at once, and the demo does not run.