Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Gradient ASCII

New

A soft glow of light drawn in ASCII characters that scramble under the pointer, with a panel for every setting.

gradient-ascii/gradient-ascii.tsx
Live · interactive

Customize

Colors
Cell Size18px
Density0.8
Glyph Opacity0.85
Gradient Opacity0.9
Glow0.2
Depth of Field0
Flicker0.2
Softness0.5
Scale1
Flow0.5
Speed1
Grain0.08
Variation0
Scramble Radius0.1

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 hero or landing background that should feel alive and technical without a video: light that drifts and wavers, typed out in characters.
  • A launch, coming-soon or product page where the pointer is invited to play: it scrambles the characters it passes and a click sends a ring out.
  • A brand moment that needs its own palette: two to eight colours, four shapes and five sets of characters cover a wide range from one component.
  • A design tool or playground where the reader tunes a generative look and copies the settings: the Customize panel exposes every one of them.

When not to use

  • Behind long reading text or a dense form. The moving characters fight the words; use a still gradient or a flat surface.
  • A page that must run without WebGL, or on very weak devices. Use a CSS radial gradient or a static image of it.
  • Many copies on one page, such as a card each. Every instance holds its own WebGL canvas; use one background behind the whole grid.
  • Showing data. The brightness is decorative noise, not a value; use a chart or a heatmap.
  • A loading state. Use a skeleton or a spinner that says something is on its way.

You can add this component with the shadcn CLI or by hand:

Into components/gradient-ascii of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/gradient-ascii.json

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

hero.tsx
import { GradientAscii } from './gradient-ascii';export function Hero() {  return (    <GradientAscii      controls={false}      autoplay={false}      shape="eclipse"      colors={['#78350f', '#f59e0b', '#fffbeb']}      characters=" 01"      cellSize={15}    />  );}

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 background of light typed out in characters: a smooth gradient underneath, and over it a grid of monospaced glyphs that grows denser where the light is brighter. It drifts and wavers on its own; the pointer scrambles the characters it passes, and a click sends a ring of them out. Four shapes, five sets of characters and a Customize panel tune the rest.

  • One WebGL fragment shader draws the whole picture at the canvas’s device pixels: the light per pixel, and a character per cell over it. A slow value noise warps the light so it wavers as it drifts; the speed scales that clock, and 0 holds the shape still.
  • Each shape gives three things at a point: how much light there is, how far into it the point lies, and where it sits across it. The first colour is the edge of the shadow; the rest run across the light: on the horizon from the sun out to the far sky, on pinch, rise and eclipse from the outskirts in to the heart of the light. Dark areas stay transparent, so the stage shows through.
  • Cells are 0.6 as wide as they are tall and centred on the canvas. Each cell asks the light at its middle how bright it is and picks a character further along the ramp the brighter it is. A seeded noise empties the dimmer cells first (density), and now and then a cell swaps to a neighbouring brightness (flicker).
  • The glyphs come from an atlas drawn once on a 2D canvas at the cell’s exact size in device pixels, so they stay crisp; a blurred twin of it gives the glow and the out-of-focus characters. Blocks and dots are drawn as shapes, so they fill their cells whatever the font.
  • Scrambling is an energy per cell kept on the CPU and sent as a small texture: the pointer stamps it along its path (scrambleRadius of the height around it), and it drains in about half a second. A click’s ring is drawn by the shader itself, a band one cell wide travelling at about one height a second; a burst stamps a wide patch instead. A scrambled cell shows a random character of the ramp, changed sixteen times a second.
  • The intro fades the light in from its source over 2.2 s (the planet’s edge, the band’s heart, the bottom, the disc’s rim), its far colours coming last, while the cells it reaches decode. The replay button in the corner plays it again; changing the shape plays it too.
  • It draws only while on screen and the tab is visible. Under reduced motion it draws a single still frame, the light fully in, nothing drifting or scrambling.
  • The Customize panel is three columns of 40 px rows (two under 600 px, one under 380 px): the colours (two to eight, each a native colour input), four menus, fourteen scrubbers you press or drag anywhere on the row, and three switches. A preset sets a palette, a shape, a set of characters and a cell size together; Reset goes back to the props.
  • Every colour of the stage and the panel is a light-dark() token, so it follows the page’s color-scheme or the colorScheme prop. On a light stage Light Mode is on by default: the light becomes a wash and the characters a darker shade of it.
PropTypeDefaultDescription
colorsstring[]['#1e3fd8', '#f6f8ff', '#a5d8ff', '#38bdf8']Two to eight colours. The first is the edge of the shadow; the rest run across the light (on a horizon from the sun to the far sky, on the other shapes from the outskirts to the heart). Dark areas stay transparent.
shape'horizon' | 'pinch' | 'rise' | 'eclipse''horizon'Horizon glows over a dark curve, pinch is a band of light squeezed in the middle, rise wells up from the bottom and eclipse rings a dark disc.
charactersstring' =+xX0S8#@'Characters from dimmest to brightest; a space leaves a cell empty. CHARACTER_SETS has five: Letters, Classic, Binary, Blocks and Dots.
cellSizenumber18Height of a character cell in px, 8 to 40; cells are 0.6 as wide.
densitynumber0.8Share of cells that show a character, 0 to 1. Brighter areas fill in first.
glyphOpacitynumber0.85How strongly the characters stand out from the gradient, 0 to 1.
gradientOpacitynumber0.9Opacity of the gradient behind the characters, 0 to 1. At 0 only the characters are left.
glownumber0.2Soft glow around the characters, 0 to 1.5.
depthOfFieldnumber0Share of characters drawn out of focus, 0 to 1.
flickernumber0.2How often characters swap to a neighbouring brightness, 0 to 1.
softnessnumber0.5How soft the edges of the shape are, 0 to 1.
scalenumber1Size of the shape, 0.4 to 2.5.
flownumber0.5How much the light wavers as it drifts, 0 to 2.
speednumber1Speed of the drift, 0 to 3. 0 holds the shape still.
grainnumber0.08Strength of the film grain, 0 to 0.4.
variationnumber0A seed, 0 to 10: a different drift and a different pattern of characters.
mouseInteractionbooleantrueThe pointer scrambles the characters it passes; they settle back half a second later.
scrambleRadiusnumber0.1Size of the scrambled area around the pointer, as a share of the height (0.02 to 0.4).
clickEffect'wave' | 'burst' | 'none''wave'Wave sends a ring of scrambled characters out from a click; burst scrambles a patch under it.
introbooleantrueFades the light in from its source while the characters decode.
lightModeboolean—Draws the light as a wash for a light background, with characters a shade darker. Unset, it follows the colour scheme.
fontFamilystring'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace'Font of the characters. A monospaced font keeps the grid even.
dprnumber2The highest device pixel ratio it draws at; never over 2.
controlsbooleantrueShows the Customize panel under the stage. Turn it off to use it as a background.
onSettingsChange(settings: GradientSettings) => void—Called with all the settings after every change made on the panel.
autoplaybooleantrueWhile on screen and untouched, a demo pointer wanders through the light, scrambling as it goes, and clicks every 4.2 s. Any real input hands it to the reader.
colorScheme'light' | 'dark' | 'system'—Forces a colour scheme; by default it follows the page.
classNamestring—Extra class names for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

gradient-ascii.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useMemo,  useRef,  useState,  type PointerEvent as ReactPointerEvent,  type RefObject,} from 'react';import { Panel } from './panel';import { DEFAULTS, presetOf, type GradientSettings, type Shape } from './presets';import { createRenderer, type Renderer } from './renderer';import { useAutoplay } from './use-autoplay';import styles from './gradient-ascii.module.css';export { CHARACTER_SETS, PRESETS, DEFAULTS } from './presets';export type { ClickEffect, GradientSettings, Shape } from './presets';/* * Gradient ASCII: a soft glow of light drawn in characters, as a background. Under it lies a
renderer.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { GradientSettings, Shape } from './presets';/* * The renderer: one WebGL fragment shader draws the whole picture, the soft light and the * characters over it, at the canvas's device pixels. * * - The light is a field worked out per pixel: each shape gives how much light there is (L), how *   far into it a point lies (m: the edge colour gives way to the light's own), and where it sits *   across the light (u, which picks among the colours after the first). A slow noise warps the *   field so it wavers as it drifts. * - The characters live on a grid of cells, each 0.6 as wide as it is tall. A cell asks the same *   field at its middle how bright it is and picks a character from the ramp; a seeded noise *   leaves the dimmer cells empty first (density), and now and then a cell swaps with a *   neighbouring brightness (flicker). * - The glyphs come from an atlas drawn once on a 2D canvas, at the cell's exact size in device *   px, so they stay crisp; a blurred twin of it gives the glow and the out-of-focus characters. *   Blocks and dots are drawn as shapes, so they fill their cells whatever the font. * - Scrambling is an energy per cell, kept on the CPU (a few thousand bytes) and sent as a small *   texture: the pointer and a click's burst raise it, and it drains in about half a second. A *   click's ring is drawn by the shader itself, a thin band one cell wide travelling at about one *   height of the canvas a second. A cell with energy shows a random character from the ramp, *   changed sixteen times a second.
panel.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useId,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type PointerEvent as ReactPointerEvent,} from 'react';import {  CHARACTER_SETS,  PRESETS,  RANGES,  characterSetOf,  presetOf,  type CharacterSet,  type ClickEffect,  type GradientSettings,  type NumberSetting,  type Preset,
presets.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The settings a gradient takes, their defaults and ranges, and the named starting points: four * presets (a palette, a shape, a set of characters and a cell size each) and five sets of * characters. The panel and the renderer both read from here. */export type Shape = 'horizon' | 'pinch' | 'rise' | 'eclipse';export type ClickEffect = 'wave' | 'burst' | 'none';export type GradientSettings = {  /**   * Two to eight colours. The first is the edge of the shadow; the rest run across the light: on   * a horizon from the sun out to the far sky, on the other shapes from the outskirts in to the   * heart of the light.   */  colors: string[];  shape: Shape;  /** Characters from dimmest to brightest; a space leaves a cell empty. */  characters: string;  /** Height of a character cell in px; cells are 0.6 as wide. */  cellSize: number;  density: number;
use-autoplay.ts
// 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(
gradient-ascii.module.css
/* Gradient ASCII (gradient-ascii.tsx): a framed stage with the light drawn in characters, and   under it the Customize card with its panel of rows in three columns (two under 600px, one   under 380px: a container query on the component's own width).   Colours are the tokens below, light-dark(light, dark) where the two differ: 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 light itself is drawn by the shader in   renderer.ts from its own colours; only the stage behind it comes from here.   Memory. The canvas is one layer, sized by its frame; the panel's fills, handles and knobs move   by width and transform on rows of fixed height, so a change never lays out the page around. */.root {  /* The stage behind the light, and its frame's edge. */  --stage: #f6f5f8;  --stage-edge: #e3e1e8;  /* The Customize card (the dark one is a white veil of 4% over the stage), the panel inside it     and its edge. */  --card: #efeef2;  --panel: #fff;  --panel-edge: rgb(0 0 0 / 0.07);  /* A row, a scrubber's fill, its ticks and its handle. */  --row: rgb(0 0 0 / 0.035);  --fill: rgb(0 0 0 / 0.05);
  • The stage is one image with a label naming the shape (role="img"); the canvas and the demo pointer are hidden from assistive technology.
  • Every scrubber is a slider with its label, range and value (aria-valuetext with its unit): arrows step it, Page Up and Down take bigger steps, Home and End go to the ends.
  • Menus are buttons that open a listbox: the arrows move, Enter or Space picks, Escape closes and gives the focus back. Switches are buttons with role="switch"; each colour is a native colour input with its own label.
  • Picking a preset or resetting is announced in a polite live region. Every control shows a focus ring for the keyboard.
  • Under reduced motion it draws one still frame: nothing drifts, decodes or scrambles, and the demo stays off. It stops drawing offscreen and in hidden tabs.