Quill
import { useEffect, useId, useRef, useState } from 'react';
const WET = 800; // ms a fresh letter stays wet
const css = `
@keyframes field-quill-dip-a { 0%, 100% { transform: none; } 40% { transform: translate(14px, 6px) rotate(18deg); } }
@keyframes field-quill-dip-b { 0%, 100% { transform: none; } 40% { transform: translate(14px, 6px) rotate(18deg); } }
.field-quill { width: 260px; }
.field-quill__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-quill__box {
position: relative;
display: flex;
align-items: center;
height: 48px;
border-bottom: 1.5px solid light-dark(#cbced3, #393c41);
transition: border-color 0.2s;
}
.field-quill__box:focus-within { border-color: light-dark(#50535b, #c6c8cd); }
.field-quill__input, .field-quill__mirror { padding: 0 34px 0 2px; font-family: Georgia, 'Times New Roman', serif; font-size: 20px; font-style: italic; letter-spacing: 0.01em; }
.field-quill__input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#50535b, #c6c8cd); }
.field-quill__input::placeholder { color: light-dark(#6d7179, #7d8087); }
.field-quill__mirror { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: light-dark(#42454c, #e5e6e9); white-space: pre; pointer-events: none; }
/* Wet ink: darker, a touch heavier and blooming, then it dries to the settled colour. */
.field-quill__ch { transition: color 0.8s, text-shadow 0.8s; }
.field-quill__ch[data-wet] { color: light-dark(#373a40, #f1f2f3); text-shadow: 0 0 0.6px light-dark(#373a40, #f1f2f3), 0 0 4px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 35%, transparent); transition: none; }
.field-quill__nib { position: relative; display: inline-block; width: 0; height: 1em; }
/* Before the first letter the quill rests in the pot, out of the placeholder's way. */
.field-quill[data-empty] .field-quill__nib { display: none; }
.field-quill__rest { position: absolute; right: 8px; bottom: 12px; width: 22px; height: 30px; overflow: visible; transform: rotate(10deg); transform-origin: 0 100%; }
.field-quill__rest path { fill: light-dark(#50535b, #c6c8cd); }
.field-quill__rest .field-quill__vane { fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 55%, transparent); }
.field-quill__rest .field-quill__shaft { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.1; }
.field-quill:not([data-empty]) .field-quill__rest { display: none; }
.field-quill__nib svg { position: absolute; left: 1px; bottom: 6px; width: 22px; height: 30px; overflow: visible; transform-origin: 0 100%; }
.field-quill__nib path { fill: light-dark(#50535b, #c6c8cd); }
.field-quill__nib .field-quill__shaft { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.1; }
.field-quill__nib .field-quill__vane { fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 55%, transparent); }
.field-quill[data-dip='a'] .field-quill__nib svg { animation: field-quill-dip-a 0.5s ease-in-out; }
.field-quill[data-dip='b'] .field-quill__nib svg { animation: field-quill-dip-b 0.5s ease-in-out; }
.field-quill__pot { position: absolute; right: 2px; bottom: 6px; width: 20px; height: 16px; border-radius: 4px 4px 6px 6px; background: light-dark(#dbdde1, #2e3035); box-shadow: inset 0 3px 0 -1px light-dark(#fbfcfd, #121417), inset 0 0 0 1px light-dark(#cbced3, #393c41); }
@media (prefers-reduced-motion: reduce) {
.field-quill__nib svg { animation: none !important; }
.field-quill__ch { transition: none; }
}
`;
export function QuillInput() {
const id = useId();
const [value, setValue] = useState('');
const [times, setTimes] = useState<number[]>([]);
const [, tick] = useState(0);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const onChange = (next: string) => {
const now = performance.now();
// Keep the times of the letters that did not change: the common start and end.
let a = 0;
while (a < value.length && a < next.length && value[a] === next[a]) a++;
let b = 0;
while (b < value.length - a && b < next.length - a && value[value.length - 1 - b] === next[next.length - 1 - b]) b++;
setTimes([...times.slice(0, a), ...Array(next.length - a - b).fill(now), ...times.slice(times.length - b)]);
setValue(next);
clearTimeout(timer.current);
timer.current = setTimeout(() => tick((n) => n + 1), WET + 20);
};
const now = typeof performance === 'undefined' ? 0 : performance.now();
const words = value.trim() ? value.trim().split(/\s+/).length : 0;
// Every third word the quill goes back to the pot.
const dip = Math.floor(words / 3);
return (
<>
<style>
{css}
</style>
<div className="field-quill" data-empty={!value || undefined} data-dip={dip ? (dip % 2 ? 'a' : 'b') : undefined}>
<label className="field-quill__label" htmlFor={id}>
Dedication
</label>
<div className="field-quill__box">
<input
id={id}
className="field-quill__input"
value={value}
maxLength={24}
placeholder="For my mother"
autoComplete="off"
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
<span className="field-quill__mirror" aria-hidden="true">
{[...value].map((c, i) => (
<span key={i} className="field-quill__ch" data-wet={now - (times[i] ?? 0) < WET || undefined}>
{c}
</span>
))}
<span className="field-quill__nib">
<svg viewBox="0 0 22 30">
<path className="field-quill__vane" d="M2 28C6 18 12 8 21 1C19 10 13 20 4 27Z" />
<path className="field-quill__shaft" d="M1 29.5L3 25.5L4.5 26.5Z M3 25.5C8 16 13 9 21 1" />
</svg>
</span>
</span>
<svg className="field-quill__rest" viewBox="0 0 22 30" aria-hidden="true">
<path className="field-quill__vane" d="M2 28C6 18 12 8 21 1C19 10 13 20 4 27Z" />
<path className="field-quill__shaft" d="M1 29.5L3 25.5L4.5 26.5Z M3 25.5C8 16 13 9 21 1" />
</svg>
<span className="field-quill__pot" aria-hidden="true" />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldQuill(el) on the mounted element instead.
<div class="field-quill" data-empty="true">
<label class="field-quill__label" for="field-quill">Dedication</label>
<div class="field-quill__box">
<input id="field-quill" class="field-quill__input" maxLength="24" placeholder="For my mother" autoComplete="off" spellCheck="false" value=""/>
<span class="field-quill__mirror" aria-hidden="true"><span class="field-quill__nib"><svg viewBox="0 0 22 30"><path class="field-quill__vane" d="M2 28C6 18 12 8 21 1C19 10 13 20 4 27Z"></path><path class="field-quill__shaft" d="M1 29.5L3 25.5L4.5 26.5Z M3 25.5C8 16 13 9 21 1"></path></svg></span></span>
<svg class="field-quill__rest" viewBox="0 0 22 30" aria-hidden="true">
<path class="field-quill__vane" d="M2 28C6 18 12 8 21 1C19 10 13 20 4 27Z"></path>
<path class="field-quill__shaft" d="M1 29.5L3 25.5L4.5 26.5Z M3 25.5C8 16 13 9 21 1"></path>
</svg>
<span class="field-quill__pot" aria-hidden="true"></span>
</div>
</div>@keyframes field-quill-dip-a { 0%, 100% { transform: none; } 40% { transform: translate(14px, 6px) rotate(18deg); } }
@keyframes field-quill-dip-b { 0%, 100% { transform: none; } 40% { transform: translate(14px, 6px) rotate(18deg); } }
.field-quill { width: 260px; }
.field-quill__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-quill__box {
position: relative;
display: flex;
align-items: center;
height: 48px;
border-bottom: 1.5px solid light-dark(#cbced3, #393c41);
transition: border-color 0.2s;
}
.field-quill__box:focus-within { border-color: light-dark(#50535b, #c6c8cd); }
.field-quill__input, .field-quill__mirror { padding: 0 34px 0 2px; font-family: Georgia, 'Times New Roman', serif; font-size: 20px; font-style: italic; letter-spacing: 0.01em; }
.field-quill__input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#50535b, #c6c8cd); }
.field-quill__input::placeholder { color: light-dark(#6d7179, #7d8087); }
.field-quill__mirror { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: light-dark(#42454c, #e5e6e9); white-space: pre; pointer-events: none; }
/* Wet ink: darker, a touch heavier and blooming, then it dries to the settled colour. */
.field-quill__ch { transition: color 0.8s, text-shadow 0.8s; }
.field-quill__ch[data-wet] { color: light-dark(#373a40, #f1f2f3); text-shadow: 0 0 0.6px light-dark(#373a40, #f1f2f3), 0 0 4px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 35%, transparent); transition: none; }
.field-quill__nib { position: relative; display: inline-block; width: 0; height: 1em; }
/* Before the first letter the quill rests in the pot, out of the placeholder's way. */
.field-quill[data-empty] .field-quill__nib { display: none; }
.field-quill__rest { position: absolute; right: 8px; bottom: 12px; width: 22px; height: 30px; overflow: visible; transform: rotate(10deg); transform-origin: 0 100%; }
.field-quill__rest path { fill: light-dark(#50535b, #c6c8cd); }
.field-quill__rest .field-quill__vane { fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 55%, transparent); }
.field-quill__rest .field-quill__shaft { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.1; }
.field-quill:not([data-empty]) .field-quill__rest { display: none; }
.field-quill__nib svg { position: absolute; left: 1px; bottom: 6px; width: 22px; height: 30px; overflow: visible; transform-origin: 0 100%; }
.field-quill__nib path { fill: light-dark(#50535b, #c6c8cd); }
.field-quill__nib .field-quill__shaft { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.1; }
.field-quill__nib .field-quill__vane { fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 55%, transparent); }
.field-quill[data-dip='a'] .field-quill__nib svg { animation: field-quill-dip-a 0.5s ease-in-out; }
.field-quill[data-dip='b'] .field-quill__nib svg { animation: field-quill-dip-b 0.5s ease-in-out; }
.field-quill__pot { position: absolute; right: 2px; bottom: 6px; width: 20px; height: 16px; border-radius: 4px 4px 6px 6px; background: light-dark(#dbdde1, #2e3035); box-shadow: inset 0 3px 0 -1px light-dark(#fbfcfd, #121417), inset 0 0 0 1px light-dark(#cbced3, #393c41); }
@media (prefers-reduced-motion: reduce) {
.field-quill__nib svg { animation: none !important; }
.field-quill__ch { transition: none; }
}function initFieldQuill(root) {
var WET = 800; // ms a fresh letter stays wet
var input = root.querySelector('.field-quill__input');
var mirror = root.querySelector('.field-quill__mirror');
var nib = root.querySelector('.field-quill__nib');
var value = '';
var times = [];
var timer;
function render() {
var now = performance.now();
var chars = mirror.querySelectorAll('.field-quill__ch');
for (var k = chars.length; k < value.length; k++) {
var el = document.createElement('span');
el.className = 'field-quill__ch';
mirror.insertBefore(el, nib);
}
for (var j = chars.length - 1; j >= value.length; j--) chars[j].remove();
mirror.querySelectorAll('.field-quill__ch').forEach(function (el, i) {
el.textContent = value[i];
el.toggleAttribute('data-wet', now - (times[i] || 0) < WET);
});
root.toggleAttribute('data-empty', !value);
// Every third word the quill goes back to the pot.
var words = value.trim() ? value.trim().split(/\s+/).length : 0;
var dip = Math.floor(words / 3);
if (dip) root.setAttribute('data-dip', dip % 2 ? 'a' : 'b');
else root.removeAttribute('data-dip');
}
function onInput() {
var next = input.value;
var now = performance.now();
// Keep the times of the letters that did not change: the common start and end.
var a = 0;
while (a < value.length && a < next.length && value[a] === next[a]) a++;
var b = 0;
while (b < value.length - a && b < next.length - a && value[value.length - 1 - b] === next[next.length - 1 - b]) b++;
times = times.slice(0, a).concat(new Array(next.length - a - b).fill(now), times.slice(times.length - b));
value = next;
render();
// Once the newest letter has dried, draw again so it settles.
clearTimeout(timer);
timer = setTimeout(render, WET + 20);
}
input.addEventListener('input', onInput);
return function () {
clearTimeout(timer);
input.removeEventListener('input', onInput);
};
}
document.querySelectorAll('.field-quill').forEach(initFieldQuill);Goal: Create a "Quill" input.
Visual behavior: A dedication field written with a quill: the quill rides the end of your words, fresh letters land in wet ink that blooms and then dries, and every few words it dips back in the pot.
Technique:
- The real input keeps transparent text and a visible caret; an aria-hidden mirror with the same font, size and padding draws the characters on top, so the caret always lines up.
- Each character remembers when it was typed (characters that did not change keep their time), and is marked data-wet for 800ms: brighter, with a soft bloom, then it transitions to the settled ink. A timer redraws once the newest letter has dried.
- A drawn quill sits after the last letter in the mirror; every third word it dips with a keyframe, picked by a data-dip attribute that flips between a and b. Before the first letter it rests in the pot, clear of the placeholder.
- spellCheck is off, so no underline shows through the transparent text.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. Under prefers-reduced-motion the quill does not dip and the ink dries without fading.
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 "Quill" input.
Visual behavior: A dedication field written with a quill: the quill rides the end of your words, fresh letters land in wet ink that blooms and then dries, and every few words it dips back in the pot.
Technique:
- The real input keeps transparent text and a visible caret; an aria-hidden mirror with the same font, size and padding draws the characters on top, so the caret always lines up.
- Each character remembers when it was typed (characters that did not change keep their time), and is marked data-wet for 800ms: brighter, with a soft bloom, then it transitions to the settled ink. A timer redraws once the newest letter has dried.
- A drawn quill sits after the last letter in the mirror; every third word it dips with a keyframe, picked by a data-dip attribute that flips between a and b. Before the first letter it rests in the pot, clear of the placeholder.
- spellCheck is off, so no underline shows through the transparent text.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. Under prefers-reduced-motion the quill does not dip and the ink dries without fading.
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 dedication field written with a quill: the quill rides the end of your words, fresh letters land in wet ink that blooms and then dries, and every few words it dips back in the pot.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- focus, state change, glow
- Techniques
- text animation, keyframes, css transition, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. Under prefers-reduced-motion the quill does not dip and the ink dries without fading.