ASCII
import { useEffect, useRef, useState } from 'react';
const CELLS = 20;
const SPIN = ['|', '/', '-', '\\'];
const cellOf = (v: number) => Math.round((v / 100) * (CELLS - 1));
const css = `
.slider-ascii { position: relative; width: 22ch; font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: light-dark(#6d7179, #7d8087); }
.slider-ascii__top { display: flex; justify-content: space-between; margin: 0 0 4px; font-size: 12px; letter-spacing: 0.08em; }
.slider-ascii__top b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; white-space: pre; }
.slider-ascii__bar { position: relative; margin: 0; white-space: pre; color: light-dark(#cbced3, #393c41); border-radius: 2px; }
.slider-ascii__edge { color: light-dark(#5f626a, #a7abb2); }
.slider-ascii__on { color: light-dark(#373a40, #f1f2f3); }
.slider-ascii__tip { color: light-dark(#373a40, #f1f2f3); font-weight: 700; }
.slider-ascii:has(.slider-ascii__input:focus-visible) .slider-ascii__bar { outline: 1px dashed light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.slider-ascii__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-ascii__input::-webkit-slider-thumb { -webkit-appearance: none; width: 1ch; height: 21px; }
.slider-ascii__input::-moz-range-thumb { width: 1ch; height: 21px; border: 0; }
`;
export function AsciiSlider() {
const [v, setV] = useState(55);
const [drawn, setDrawn] = useState(cellOf(55));
const [moving, setMoving] = useState(false);
const [spin, setSpin] = useState(0);
const cur = useRef(cellOf(55));
const t = useRef({ step: 0, idle: 0 });
// The bar redraws towards the value one character at a time, like a terminal repainting.
useEffect(() => {
const target = cellOf(v);
const q = t.current;
clearTimeout(q.step);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
cur.current = target;
setDrawn(target);
return;
}
const tick = () => {
if (cur.current === target) return;
cur.current += Math.sign(target - cur.current);
setDrawn(cur.current);
q.step = window.setTimeout(tick, 22);
};
tick();
return () => clearTimeout(q.step);
}, [v]);
// While the value is changing the head spins.
useEffect(() => {
if (!moving) return;
const id = window.setInterval(() => setSpin((s) => s + 1), 70);
return () => clearInterval(id);
}, [moving]);
useEffect(() => () => clearTimeout(t.current.idle), []);
const tip = moving ? SPIN[spin % SPIN.length] : '>';
return (
<>
<style>
{css}
</style>
<div className="slider-ascii">
<p className="slider-ascii__top" aria-hidden="true">
<span>VOLUME</span>
<b>{String(v).padStart(3, ' ')}%</b>
</p>
<div className="slider-ascii__bar">
<span aria-hidden="true">
<span className="slider-ascii__edge">[</span>
<span className="slider-ascii__on">{'='.repeat(drawn)}</span>
<span className="slider-ascii__tip">{tip}</span>
<span className="slider-ascii__off">{'·'.repeat(CELLS - drawn - 1)}</span>
<span className="slider-ascii__edge">]</span>
</span>
<input
className="slider-ascii__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => {
setV(+e.target.value);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
setMoving(true);
clearTimeout(t.current.idle);
t.current.idle = window.setTimeout(() => setMoving(false), 260);
}}
aria-label="Volume"
aria-valuetext={`${v}%`}
/>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderAscii(el) on the mounted element instead.
<div class="slider-ascii">
<p class="slider-ascii__top" aria-hidden="true">
<span>VOLUME</span>
<b> 55%</b>
</p>
<div class="slider-ascii__bar">
<span aria-hidden="true">
<span class="slider-ascii__edge">[</span>
<span class="slider-ascii__on">==========</span>
<span class="slider-ascii__tip">></span>
<span class="slider-ascii__off">·········</span>
<span class="slider-ascii__edge">]</span>
</span>
<input class="slider-ascii__input" type="range" min="0" max="100" aria-label="Volume" aria-valuetext="55%" value="55"/>
</div>
</div>.slider-ascii { position: relative; width: 22ch; font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: light-dark(#6d7179, #7d8087); }
.slider-ascii__top { display: flex; justify-content: space-between; margin: 0 0 4px; font-size: 12px; letter-spacing: 0.08em; }
.slider-ascii__top b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; white-space: pre; }
.slider-ascii__bar { position: relative; margin: 0; white-space: pre; color: light-dark(#cbced3, #393c41); border-radius: 2px; }
.slider-ascii__edge { color: light-dark(#5f626a, #a7abb2); }
.slider-ascii__on { color: light-dark(#373a40, #f1f2f3); }
.slider-ascii__tip { color: light-dark(#373a40, #f1f2f3); font-weight: 700; }
.slider-ascii:has(.slider-ascii__input:focus-visible) .slider-ascii__bar { outline: 1px dashed light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.slider-ascii__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-ascii__input::-webkit-slider-thumb { -webkit-appearance: none; width: 1ch; height: 21px; }
.slider-ascii__input::-moz-range-thumb { width: 1ch; height: 21px; border: 0; }function initSliderAscii(root) {
var CELLS = 20;
var SPIN = ['|', '/', '-', '\\'];
var input = root.querySelector('.slider-ascii__input');
var on = root.querySelector('.slider-ascii__on');
var tip = root.querySelector('.slider-ascii__tip');
var off = root.querySelector('.slider-ascii__off');
var label = root.querySelector('.slider-ascii__top b');
var drawn = cellOf(+input.value);
var moving = false;
var spin = 0;
var stepTimer = 0;
var idleTimer = 0;
var spinTimer = 0;
function cellOf(v) { return Math.round((v / 100) * (CELLS - 1)); }
function paint() {
on.textContent = new Array(drawn + 1).join('=');
off.textContent = new Array(CELLS - drawn).join('·');
tip.textContent = moving ? SPIN[spin % SPIN.length] : '>';
}
function update() {
var v = +input.value;
var target = cellOf(v);
label.textContent = (' ' + v).slice(-3) + '%';
input.setAttribute('aria-valuetext', v + '%');
clearTimeout(stepTimer);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
drawn = target;
return paint();
}
// The bar redraws towards the value one character at a time, like a terminal repainting,
// and the head spins while the value is changing.
(function tick() {
if (drawn === target) return;
drawn += Math.sign(target - drawn);
paint();
stepTimer = setTimeout(tick, 22);
})();
moving = true;
if (!spinTimer) spinTimer = setInterval(function () {
spin += 1;
paint();
}, 70);
clearTimeout(idleTimer);
idleTimer = setTimeout(function () {
moving = false;
clearInterval(spinTimer);
spinTimer = 0;
paint();
}, 260);
}
input.addEventListener('input', update);
return function () {
clearTimeout(stepTimer);
clearTimeout(idleTimer);
clearInterval(spinTimer);
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-ascii').forEach(initSliderAscii);Goal: Create a "ASCII" slider.
Visual behavior: A volume slider drawn entirely in monospace characters, [=====> ] style: the bar redraws one character at a time and the head spins while you drag.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The bar is twenty cells: equals signs up to the head, the head, then middle dots. When the value moves, a timer steps the drawn count one cell every 22ms towards it, so a jump repaints like a terminal.
- While the value is changing the head cycles through | / - \ on a 70ms interval and returns to > once the value has rested for a quarter of a second. The readout is padded to three characters so it never shifts.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus draws a dashed outline round the bar. Under prefers-reduced-motion the bar redraws at once and the head never spins.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "ASCII" slider.
Visual behavior: A volume slider drawn entirely in monospace characters, [=====> ] style: the bar redraws one character at a time and the head spins while you drag.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The bar is twenty cells: equals signs up to the head, the head, then middle dots. When the value moves, a timer steps the drawn count one cell every 22ms towards it, so a jump repaints like a terminal.
- While the value is changing the head cycles through | / - \ on a 70ms interval and returns to > once the value has rested for a quarter of a second. The readout is padded to three characters so it never shifts.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus draws a dashed outline round the bar. Under prefers-reduced-motion the bar redraws at once and the head never spins.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A volume slider drawn entirely in monospace characters, [=====> ] style: the bar redraws one character at a time and the head spins while you drag.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- state change, continuous
- Techniques
- text animation, interval
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext in percent; keyboard focus draws a dashed outline round the bar. Under prefers-reduced-motion the bar redraws at once and the head never spins.