Glossy app icon
NewHyper-real app icons drawn in SVG and CSS: rich materials, raised piping, glass that catches the light as the icon tilts.
glossy-app-icon/glossy-app-icon.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 hero moment for one app or product: a landing page, a launch post, an about screen, where the icon itself is the thing to look at.
- An icon that should feel touchable on a desktop page: it leans towards the pointer and sinks when pressed, like a physical key.
- A brand mark drawn at any size from one drawing, with no image file to export, host or swap for retina screens.
- A small gallery of a few icons to compare, with the dock to switch between them on one stage.
When not to use
- A grid of many app icons, a launcher or a settings list. Use flat images or plain SVG icons; each of these carries layered filters and shadows.
- Icons inside buttons, menus or toolbars. Use a line icon set at 16 to 24 px.
- The real icon of a shipped app in a store listing. Use the exported PNG the store asks for.
- A page that has to stay quiet and flat. The deep shadow and the gloss draw the eye; use a simple tile with a logo.
- Touch-only screens where the tilt is the point: a tap only presses it. Use a short entrance animation instead.
You can add this component with the shadcn CLI or by hand:
Into components/glossy-app-icon of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/glossy-app-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
import { GlossyAppIcon } from './glossy-app-icon';export function Launcher() { return ( <GlossyAppIcon stage={false} variant="aperture" size={96} onPress={(variant) => console.log('open', variant)} /> );}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.
Four app icons in the glossy, tactile style: a masked hero’s goggle on cobalt twill, a camera lens on pebbled leather, a brushed-metal dial and a stitched canvas patch. Each sits on a pale grid of blank tiles with a deep soft shadow; hover tilts it towards the pointer while the gloss and the reflections in the glass drift across it, and a press sinks it and lets it spring back. No image files: every icon is one SVG drawing lit by CSS layers.
- Each icon is one 100-unit SVG drawing: the material as gradients, its weave as an SVG pattern (a diagonal twill, a canvas weave, satin stitches) or as SVG noise (pebbled leather, brushed metal), and every raised line as four stacked strokes: a blurred cast shadow, the tube, its highlight and a fine glint.
- The dial’s knob is spun metal: two conic gradients under a fine repeating-conic hairline, in an HTML layer over the drawing so its highlights stay put while its orange mark sits at the level.
- HTML layers light the drawing: a bevel of inset shadows (a lit top rim, a shaded bottom, a fine dark edge), a soft-light gloss from the top left, and a drop shadow of three layers that stays flat on the stage.
- Hover tilts the icon up to 11 degrees towards the pointer. The pointer’s place goes into two custom properties, --tx and --ty, and the tilt, the gloss, the shadow, the knob and the reflections inside the glass all read them, each moving by a transform at its own depth, so the reflections slide across the lens as it turns. Nothing is redrawn.
- A press scales the face down 4.5% in 110 ms, darkens it and tightens the shadow; on release it springs back in 460 ms with an overshoot.
- The stage sizes everything from one length, --s: the icon is 61% of the stage’s shorter side, and the blank tiles are the same size, 12% of it apart, fading out at the far left and right.
- While on screen and untouched, the stage tilts the icon along a slow path, presses it, and moves on to the next icon; any pointer or key hands it to the reader, and it takes over again 5 seconds later.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'visor' | 'aperture' | 'tune' | 'patch' | 'visor' | Which icon: a masked hero’s goggle, a camera lens, a brushed-metal dial or a stitched patch. On the stage, the one it starts on. |
size | number | — | The icon’s side in px. Without it, on the stage it takes 61% of the stage’s shorter side; alone, 120. |
stage | boolean | true | Draws the stage: the grid of blank tiles and the dock of icons to pick from. Without it, just the icon. |
label | string | — | The icon’s accessible name; its own name by default. |
onPress | (variant) => void | — | Called when the icon is pressed (click, tap, Enter or Space). |
tilt | boolean | true | Hover tilts the icon towards the pointer, and the reflections move with it. |
autoplay | boolean | true | On the stage, while on screen and untouched, it tilts itself, presses and moves through the icons. Any input stops it. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark stage, or the reader’s; without it, the page’s color-scheme. The icons look the same on both. |
defaultTilt | { x: number; y: number } | — | Opens tilted towards this point (-1 to 1 on each axis), as if the pointer rested there. Turns the demo off. |
defaultPressed | boolean | false | Opens pressed in, as if held down. Turns the demo off. |
className | string | — | Classes for the 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, useId, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './glossy-app-icon.module.css';/* * Glossy app icon: a hyper-real app icon, drawn in SVG and CSS with no image file. A squircle of * rich material (twill fabric, pebbled leather, brushed metal, canvas) with raised piping, a thick * dark bezel round a glass lens, a lit rim and a deep soft shadow, on a pale grid of blank tiles. * * - Every icon is one 100-unit SVG drawing (the material, its weave as an SVG pattern, the raised * lines as stacked strokes: a blurred cast shadow, the tube, its highlight) inside HTML layers/* Glossy app icon (glossy-app-icon.tsx): the icon, and the stage it sits on — a pale grid of blank tiles the icon's size, 12% of it apart, with the icon in the middle at 61% of the stage's shorter side. Every length of the icon is a share of --s, its side, so one drawing serves every size: the bevel, the gloss and the shadow scale with it, and the SVG inside is 100 units wide. The pointer's place over the icon is --tx and --ty (-1 to 1), set from the script; the tilt, the gloss, the shadow and the reflections in the glass all read them, and all ease over --ease-ms. Only transforms and opacity change, so a hover never repaints the drawing. Colours are the stage's tokens, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page, or the one its colorScheme prop sets. The icons themselves look the same on both. */.root,.bare { /* The stage and its tiles. */ --bg: #fdfdfd; --tile: #e5e5e5; --tile-2: #eaeaea; /* The icon's drop shadow. */ --drop: rgb(0 0 0 / 0.05); --drop-mid: rgb(0 0 0 / 0.24);'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 a real button named after it (“Visor, app icon”); Enter and Space press it, with the same sink and spring as a click, and a polite live region says it is opening.
- The dock is a radio group: the chosen icon is the one tab stop, the arrow keys move between them, and the choice is announced.
- All the drawing is decorative and hidden from screen readers; the focus ring sits outside the squircle.
- With reduced motion the icon never tilts or pops in and the demo does not play; the gloss still answers the pointer and a press still shows.
- The demo pauses off screen and in a hidden tab.