Nib
import { useRef, useState } from 'react';
const W = 240;
const Y = 30;
const N = 115;
const sx = (i: number) => 6 + i * 2;
const at = (v: number) => 6 + (v / 100) * (W - 12);
const css = `
.slider-nib { position: relative; width: ${W}px; height: 72px; }
.slider-nib__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 48px; overflow: visible; }
.slider-nib__guide { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.5; stroke-linecap: round; stroke-dasharray: 1 5; }
.slider-nib__ink { fill: light-dark(#373a40, #f1f2f3); }
.slider-nib__pen { fill: light-dark(#373a40, #f1f2f3); }
.slider-nib__cut { stroke: light-dark(#fbfcfd, #121417); stroke-width: 0.9; stroke-linecap: round; }
.slider-nib__hole { fill: light-dark(#fbfcfd, #121417); }
.slider-nib__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-nib:has(.slider-nib__input:focus-visible) .slider-nib__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-nib__value { position: absolute; left: 0; right: 0; top: 52px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-nib__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-nib__input { position: absolute; left: 0; top: 0; width: 100%; height: 48px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-nib__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 48px; }
.slider-nib__input::-moz-range-thumb { width: 12px; height: 48px; border: 0; }
`;
export function NibSlider() {
const [v, setV] = useState(62);
// The width of the line at every 2px, written as the nib passes over it.
const ink = useRef({ w: Array.from({ length: N }, (_, i) => 2.6 + 2.4 * Math.abs(Math.sin(i / 13))), x: at(62), t: 0 });
const write = (nv: number) => {
const q = ink.current;
const now = performance.now();
const x1 = at(nv);
const speed = (Math.abs(x1 - q.x) / Math.max(8, now - q.t)) * 1000;
// Only forward strokes lay ink: slow is heavy, fast is a hairline. Going back leaves the line as drawn.
if (x1 > q.x) {
const target = Math.max(1.4, Math.min(7, 7.4 - speed / 60));
const i0 = Math.max(0, Math.ceil((q.x - 6) / 2));
const i1 = Math.min(N - 1, Math.floor((x1 - 6) / 2));
for (let i = i0; i <= i1; i++) {
const prev = i > 0 ? q.w[i - 1] : target;
q.w[i] = prev + (target - prev) * 0.35;
}
}
q.x = x1;
q.t = now;
setV(nv);
};
const tip = at(v);
const k = Math.min(N - 1, Math.floor((tip - 6) / 2));
const w = ink.current.w;
let d = '';
for (let i = 0; i <= k; i++) d += `${i ? 'L' : 'M'} ${sx(i)} ${(Y - w[i] / 2).toFixed(2)} `;
d += `L ${tip} ${Y} `;
for (let i = k; i >= 0; i--) d += `L ${sx(i)} ${(Y + w[i] / 2).toFixed(2)} `;
return (
<>
<style>
{css}
</style>
<div className="slider-nib">
<svg className="slider-nib__svg" aria-hidden="true">
<line className="slider-nib__guide" x1={tip + 6} y1={Y} x2={W - 2} y2={Y} />
<path className="slider-nib__ink" d={`${d}Z`} />
<g className="slider-nib__head" transform={`translate(${tip} ${Y}) rotate(38)`}>
<path className="slider-nib__pen" d="M0 0 L-4.2 -11 Q-4.6 -17 0 -19 Q4.6 -17 4.2 -11 Z" />
<line className="slider-nib__cut" x1={0} y1={-2} x2={0} y2={-10} />
<circle className="slider-nib__hole" cx={0} cy={-11.5} r={1.3} />
</g>
<circle className="slider-nib__ring" cx={tip + 5} cy={Y - 8} r={13} />
</svg>
<p className="slider-nib__value" aria-hidden="true">
<span>Stroke</span>
<b>{v}%</b>
</p>
<input className="slider-nib__input" type="range" min={0} max={100} value={v} onChange={(e) => write(+e.target.value)} aria-label="Stroke" aria-valuetext={`${v}%`} />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderNib(el) on the mounted element instead.
<div class="slider-nib">
<svg class="slider-nib__svg" aria-hidden="true">
<line class="slider-nib__guide" x1="153.36" y1="30" x2="238" y2="30"></line>
<path class="slider-nib__ink" d="M 6 28.70 L 8 28.61 L 10 28.52 L 12 28.43 L 14 28.34 L 16 28.25 L 18 28.17 L 20 28.08 L 22 28.01 L 24 27.93 L 26 27.87 L 28 27.80 L 30 27.74 L 32 27.69 L 34 27.64 L 36 27.60 L 38 27.57 L 40 27.54 L 42 27.52 L 44 27.51 L 46 27.50 L 48 27.50 L 50 27.51 L 52 27.52 L 54 27.55 L 56 27.57 L 58 27.61 L 60 27.65 L 62 27.70 L 64 27.75 L 66 27.81 L 68 27.88 L 70 27.95 L 72 28.02 L 74 28.10 L 76 28.18 L 78 28.26 L 80 28.35 L 82 28.44 L 84 28.53 L 86 28.62 L 88 28.69 L 90 28.59 L 92 28.50 L 94 28.41 L 96 28.32 L 98 28.24 L 100 28.15 L 102 28.07 L 104 28.00 L 106 27.92 L 108 27.85 L 110 27.79 L 112 27.73 L 114 27.68 L 116 27.64 L 118 27.60 L 120 27.56 L 122 27.54 L 124 27.52 L 126 27.51 L 128 27.50 L 130 27.50 L 132 27.51 L 134 27.53 L 136 27.55 L 138 27.58 L 140 27.62 L 142 27.66 L 144 27.71 L 146 27.76 L 147.36 30 L 146 32.24 L 144 32.29 L 142 32.34 L 140 32.38 L 138 32.42 L 136 32.45 L 134 32.47 L 132 32.49 L 130 32.50 L 128 32.50 L 126 32.49 L 124 32.48 L 122 32.46 L 120 32.44 L 118 32.40 L 116 32.36 L 114 32.32 L 112 32.27 L 110 32.21 L 108 32.15 L 106 32.08 L 104 32.00 L 102 31.93 L 100 31.85 L 98 31.76 L 96 31.68 L 94 31.59 L 92 31.50 L 90 31.41 L 88 31.31 L 86 31.38 L 84 31.47 L 82 31.56 L 80 31.65 L 78 31.74 L 76 31.82 L 74 31.90 L 72 31.98 L 70 32.05 L 68 32.12 L 66 32.19 L 64 32.25 L 62 32.30 L 60 32.35 L 58 32.39 L 56 32.43 L 54 32.45 L 52 32.48 L 50 32.49 L 48 32.50 L 46 32.50 L 44 32.49 L 42 32.48 L 40 32.46 L 38 32.43 L 36 32.40 L 34 32.36 L 32 32.31 L 30 32.26 L 28 32.20 L 26 32.13 L 24 32.07 L 22 31.99 L 20 31.92 L 18 31.83 L 16 31.75 L 14 31.66 L 12 31.57 L 10 31.48 L 8 31.39 L 6 31.30 Z"></path>
<g class="slider-nib__head" transform="translate(147.36 30) rotate(38)">
<path class="slider-nib__pen" d="M0 0 L-4.2 -11 Q-4.6 -17 0 -19 Q4.6 -17 4.2 -11 Z"></path>
<line class="slider-nib__cut" x1="0" y1="-2" x2="0" y2="-10"></line>
<circle class="slider-nib__hole" cx="0" cy="-11.5" r="1.3"></circle>
</g>
<circle class="slider-nib__ring" cx="152.36" cy="22" r="13"></circle>
</svg>
<p class="slider-nib__value" aria-hidden="true">
<span>Stroke</span>
<b>62%</b>
</p>
<input class="slider-nib__input" type="range" min="0" max="100" aria-label="Stroke" aria-valuetext="62%" value="62"/>
</div>.slider-nib { position: relative; width: 240px; height: 72px; }
.slider-nib__svg { position: absolute; left: 0; top: 0; width: 240px; height: 48px; overflow: visible; }
.slider-nib__guide { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.5; stroke-linecap: round; stroke-dasharray: 1 5; }
.slider-nib__ink { fill: light-dark(#373a40, #f1f2f3); }
.slider-nib__pen { fill: light-dark(#373a40, #f1f2f3); }
.slider-nib__cut { stroke: light-dark(#fbfcfd, #121417); stroke-width: 0.9; stroke-linecap: round; }
.slider-nib__hole { fill: light-dark(#fbfcfd, #121417); }
.slider-nib__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-nib:has(.slider-nib__input:focus-visible) .slider-nib__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-nib__value { position: absolute; left: 0; right: 0; top: 52px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-nib__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-nib__input { position: absolute; left: 0; top: 0; width: 100%; height: 48px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-nib__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 48px; }
.slider-nib__input::-moz-range-thumb { width: 12px; height: 48px; border: 0; }function initSliderNib(root) {
var W = 240;
var Y = 30;
var N = 115;
var input = root.querySelector('.slider-nib__input');
var ink = root.querySelector('.slider-nib__ink');
var head = root.querySelector('.slider-nib__head');
var guide = root.querySelector('.slider-nib__guide');
var ring = root.querySelector('.slider-nib__ring');
var label = root.querySelector('.slider-nib__value b');
// The width of the line at every 2px, written as the nib passes over it.
var w = [];
for (var i = 0; i < N; i++) w.push(2.6 + 2.4 * Math.abs(Math.sin(i / 13)));
var x = at(+input.value);
var t = 0;
function at(v) { return 6 + (v / 100) * (W - 12); }
function sx(i) { return 6 + i * 2; }
function draw() {
var tip = at(+input.value);
var k = Math.min(N - 1, Math.floor((tip - 6) / 2));
var d = '';
for (var i = 0; i <= k; i++) d += (i ? 'L ' : 'M ') + sx(i) + ' ' + (Y - w[i] / 2).toFixed(2) + ' ';
d += 'L ' + tip + ' ' + Y + ' ';
for (var j = k; j >= 0; j--) d += 'L ' + sx(j) + ' ' + (Y + w[j] / 2).toFixed(2) + ' ';
ink.setAttribute('d', d + 'Z');
head.setAttribute('transform', 'translate(' + tip + ' ' + Y + ') rotate(38)');
guide.setAttribute('x1', tip + 6);
ring.setAttribute('cx', tip + 5);
}
// Only forward strokes lay ink: slow is heavy, fast is a hairline. Going back leaves the line as drawn.
function write() {
var now = performance.now();
var x1 = at(+input.value);
var speed = (Math.abs(x1 - x) / Math.max(8, now - t)) * 1000;
if (x1 > x) {
var target = Math.max(1.4, Math.min(7, 7.4 - speed / 60));
var i0 = Math.max(0, Math.ceil((x - 6) / 2));
var i1 = Math.min(N - 1, Math.floor((x1 - 6) / 2));
for (var i = i0; i <= i1; i++) {
var prev = i > 0 ? w[i - 1] : target;
w[i] = prev + (target - prev) * 0.35;
}
}
x = x1;
t = now;
label.textContent = input.value + '%';
input.setAttribute('aria-valuetext', input.value + '%');
draw();
}
input.addEventListener('input', write);
return function () {
input.removeEventListener('input', write);
};
}
document.querySelectorAll('.slider-nib').forEach(initSliderNib);Goal: Create a "Nib" slider.
Visual behavior: The thumb is a pen nib and the fill is the line it draws: drag slowly for a heavy stroke and quickly for a hairline, so the fill keeps a record of how you moved.
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 line keeps a width for every 2px of track. Moving forward, the speed between input events sets a target width (slow is wide, fast is thin) and each passed sample eases towards it from the one before, so the stroke swells and tapers smoothly. Moving back leaves the widths as drawn.
- The fill is one SVG path: along the top edge at half the width above the line, to the tip, and back along the bottom edge. The nib is a small pointed shape with a slit and breather hole, tilted as if held.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the nib. Nothing animates on its own.
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 "Nib" slider.
Visual behavior: The thumb is a pen nib and the fill is the line it draws: drag slowly for a heavy stroke and quickly for a hairline, so the fill keeps a record of how you moved.
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 line keeps a width for every 2px of track. Moving forward, the speed between input events sets a target width (slow is wide, fast is thin) and each passed sample eases towards it from the one before, so the stroke swells and tapers smoothly. Moving back leaves the widths as drawn.
- The fill is one SVG path: along the top edge at half the width above the line, to the tip, and back along the bottom edge. The nib is a small pointed shape with a slit and breather hole, tilted as if held.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the nib. Nothing animates on its own.
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
The thumb is a pen nib and the fill is the line it draws: drag slowly for a heavy stroke and quickly for a hairline, so the fill keeps a record of how you moved.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, draw, fill
- Techniques
- svg, event position
- 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 rings the nib. Nothing animates on its own.