wave text
[github]a swell running through a line of text, left to right
type 36×5 15 fps 3.2 kB
making waves making waves
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script>
<ascii-art piece="wave-text" options='{"text":"making waves","amp":1.25,"wavelength":18,"speed":8,"spacing":0,"settle":true}'></ascii-art> No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.
react
// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { waveText } from "ascii.rest/pieces";
<Ascii piece={waveText} options={{"text":"making waves","amp":1.25,"wavelength":18,"speed":8,"spacing":0,"settle":true}} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="wave-text", to fetch the piece only when it mounts.
astro
---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---
<Ascii piece="wave-text" options={{"text":"making waves","amp":1.25,"wavelength":18,"speed":8,"spacing":0,"settle":true}} /> Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.
typescript
// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { waveText } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, waveText, {"text":"making waves","amp":1.25,"wavelength":18,"speed":8,"spacing":0,"settle":true}); mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.
/*
* wave-text: a swell rolls into a line of text from the left, lifts and drops
* each letter in turn, runs off the right, and the line settles level again.
*/
import type { Frame, Meta } from "../types.ts";
export interface WaveTextOptions {
[key: string]: unknown;
text: string;
amp: number;
wavelength: number;
speed: number;
spacing: number;
settle: boolean;
}
export const meta = {
name: "wave text",
category: "type",
note: "a swell running through a line of text, left to right",
cols: 36,
rows: 5,
fps: 15,
options: { text: "making waves", amp: 1.25, wavelength: 18, speed: 8, spacing: 0, settle: true },
} satisfies Meta<WaveTextOptions>;
export default function waveText({
text = meta.options.text,
amp = meta.options.amp, // rows from the level line to a crest
wavelength = meta.options.wavelength, // letters from crest to crest
speed = meta.options.speed, // letters a second the swell travels
spacing = meta.options.spacing, // blank columns between letters
settle = meta.options.settle, // level water between swells, or one endless swell
}: Partial<WaveTextOptions> = {}): Frame {
const { cols, rows } = meta;
const base = (rows - 1) / 2;
const A = Math.min(Math.max(Number(amp) || 0, 0), base + 0.45);
const W = Math.max(Number(wavelength) || 18, 4);
const v = Math.max(Number(speed) || 8, 0.5);
const step = 1 + Math.max(0, Math.floor(Number(spacing) || 0));
const chars = [...String(text)].slice(0, Math.floor((cols - 1) / step) + 1);
const n = chars.length;
const x0 = Math.floor((cols - (n - 1) * step - 1) / 2);
// One pass: half a wavelength to build, a wavelength and a half at full
// height, half a wavelength to fade, then level water until the tail has
// cleared the last letter. The envelope is zero at the seam, so the loop
// never pops.
const rise = W / 2, top = 1.5 * W, pass = 2 * rise + top;
const L = pass + n;
// Settling, t = 0 has the front just reaching the first letter, so the line
// starts level. Endless, it starts with a crest over the middle letter,
// where the wave is flattest.
const start = settle ? 0 : W / 4 + (n - 1) / 2;
const envelope = (u: number): number => {
if (!settle) return 1;
if (u < rise) return Math.sin((Math.PI / 2) * (u / rise)) ** 2;
if (u < rise + top) return 1;
if (u < pass) return Math.cos((Math.PI / 2) * ((u - rise - top) / rise)) ** 2;
return 0;
};
const height = (u: number): number => {
const w = settle ? ((u % L) + L) % L : u;
return A * envelope(w) * Math.sin((2 * Math.PI * w) / W);
};
return (t) => {
const grid = Array.from({ length: rows }, () => new Array<string>(cols).fill(" "));
const s = start + t * v;
for (let i = 0; i < n; i++) {
if (chars[i] === " ") continue;
const u = s - i;
const h = height(u);
const lift = Math.round(h);
const x = x0 + i * step;
grid[base - lift][x] = chars[i];
// A letter that has only just stepped off the level line leaves a dot
// where it sat, so the step reads as motion rather than a jump.
if (lift > 0 && h < 0.7 && height(u + 0.5) > h) grid[base][x] = "·";
}
return grid.map((r) => r.join("")).join("\n");
};
}