Ink
import { useRef, useState } from 'react';
const css = `
@keyframes field-ink-in { from { opacity: 0; filter: blur(4px); transform: scale(1.3); } 60% { opacity: 1; filter: blur(0.6px); } }
@keyframes field-ink-blot { from { opacity: 0.55; transform: scale(0.3); } to { opacity: 0; transform: scale(1.9); } }
.field-ink { width: 250px; }
.field-ink__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-ink__card {
position: relative;
height: 56px;
border-radius: 6px;
background:
linear-gradient(transparent 39px, light-dark(#dbdde1, #2e3035) 39px 40px, transparent 40px),
light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
transition: box-shadow 0.2s;
}
.field-ink__card:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-ink__input, .field-ink__mirror { padding: 0 14px; font: italic 19px Georgia, 'Times New Roman', serif; font-kerning: none; letter-spacing: 0.2px; }
.field-ink__input { position: relative; z-index: 1; width: 100%; height: 100%; padding-top: 6px; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#42454c, #e5e6e9); }
.field-ink__input::placeholder { color: light-dark(#5f626a, #a7abb2); }
.field-ink__mirror { position: absolute; inset: 0; display: flex; align-items: center; padding-top: 6px; overflow: hidden; color: light-dark(#373a40, #f1f2f3); white-space: pre; pointer-events: none; }
.field-ink__ch { position: relative; display: inline-block; animation: field-ink-in 0.7s cubic-bezier(0.2, 0.7, 0.3, 1) both; }
/* Each letter bleeds a little blot into the paper as it lands. */
.field-ink__ch::before {
content: '';
position: absolute;
left: 50%;
top: 55%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 40%, transparent), transparent 70%);
opacity: 0;
animation: field-ink-blot 0.9s ease-out;
}
@media (prefers-reduced-motion: reduce) { .field-ink__ch, .field-ink__ch::before { animation: none; } }
`;
export function InkInput() {
const [value, setValue] = useState('For everyone who');
const ids = useRef<number[]>([...'For everyone who'].map((_, i) => -1 - i));
const seq = useRef(0);
const change = (next: string) => {
let p = 0;
while (p < value.length && p < next.length && value[p] === next[p]) p++;
let s = 0;
while (s < value.length - p && s < next.length - p && value[value.length - 1 - s] === next[next.length - 1 - s]) s++;
// New letters get new keys (so they ink in); untouched ones keep theirs.
ids.current = [...ids.current.slice(0, p), ...Array.from({ length: next.length - p - s }, () => seq.current++), ...ids.current.slice(ids.current.length - s)];
setValue(next);
};
return (
<>
<style>
{css}
</style>
<div className="field-ink">
<label className="field-ink__label" htmlFor="field-ink-input">
Dedication
</label>
<div className="field-ink__card">
<span className="field-ink__mirror" aria-hidden="true">
{[...value].map((c, i) => (
<span key={ids.current[i]} className="field-ink__ch">
{c}
</span>
))}
</span>
<input
id="field-ink-input"
className="field-ink__input"
value={value}
maxLength={24}
placeholder="Write a dedication"
autoComplete="off"
onChange={(e) => change(e.target.value)}
/>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldInk(el) on the mounted element instead.
<div class="field-ink">
<label class="field-ink__label" for="field-ink-input">Dedication</label>
<div class="field-ink__card">
<span class="field-ink__mirror" aria-hidden="true"><span class="field-ink__ch">F</span><span class="field-ink__ch">o</span><span class="field-ink__ch">r</span><span class="field-ink__ch"> </span><span class="field-ink__ch">e</span><span class="field-ink__ch">v</span><span class="field-ink__ch">e</span><span class="field-ink__ch">r</span><span class="field-ink__ch">y</span><span class="field-ink__ch">o</span><span class="field-ink__ch">n</span><span class="field-ink__ch">e</span><span class="field-ink__ch"> </span><span class="field-ink__ch">w</span><span class="field-ink__ch">h</span><span class="field-ink__ch">o</span></span>
<input id="field-ink-input" class="field-ink__input" maxLength="24" placeholder="Write a dedication" autoComplete="off" value="For everyone who"/>
</div>
</div>@keyframes field-ink-in { from { opacity: 0; filter: blur(4px); transform: scale(1.3); } 60% { opacity: 1; filter: blur(0.6px); } }
@keyframes field-ink-blot { from { opacity: 0.55; transform: scale(0.3); } to { opacity: 0; transform: scale(1.9); } }
.field-ink { width: 250px; }
.field-ink__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-ink__card {
position: relative;
height: 56px;
border-radius: 6px;
background:
linear-gradient(transparent 39px, light-dark(#dbdde1, #2e3035) 39px 40px, transparent 40px),
light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
transition: box-shadow 0.2s;
}
.field-ink__card:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-ink__input, .field-ink__mirror { padding: 0 14px; font: italic 19px Georgia, 'Times New Roman', serif; font-kerning: none; letter-spacing: 0.2px; }
.field-ink__input { position: relative; z-index: 1; width: 100%; height: 100%; padding-top: 6px; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#42454c, #e5e6e9); }
.field-ink__input::placeholder { color: light-dark(#5f626a, #a7abb2); }
.field-ink__mirror { position: absolute; inset: 0; display: flex; align-items: center; padding-top: 6px; overflow: hidden; color: light-dark(#373a40, #f1f2f3); white-space: pre; pointer-events: none; }
.field-ink__ch { position: relative; display: inline-block; animation: field-ink-in 0.7s cubic-bezier(0.2, 0.7, 0.3, 1) both; }
/* Each letter bleeds a little blot into the paper as it lands. */
.field-ink__ch::before {
content: '';
position: absolute;
left: 50%;
top: 55%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 40%, transparent), transparent 70%);
opacity: 0;
animation: field-ink-blot 0.9s ease-out;
}
@media (prefers-reduced-motion: reduce) { .field-ink__ch, .field-ink__ch::before { animation: none; } }function initFieldInk(root) {
var input = root.querySelector('.field-ink__input');
var mirror = root.querySelector('.field-ink__mirror');
if (!input || !mirror) return;
var value = input.value;
// Diff by common prefix and suffix: only the letters actually typed are new, so only they animate in.
function change() {
var next = input.value;
var p = 0;
while (p < value.length && p < next.length && value[p] === next[p]) p++;
var s = 0;
while (s < value.length - p && s < next.length - p && value[value.length - 1 - s] === next[next.length - 1 - s]) s++;
var spans = Array.prototype.slice.call(mirror.children);
spans.slice(p, spans.length - s).forEach(function (el) { el.remove(); });
var after = spans[spans.length - s] || null;
next.slice(p, next.length - s).split('').forEach(function (c) {
var el = document.createElement('span');
el.className = 'field-ink__ch';
el.textContent = c;
mirror.insertBefore(el, after);
});
value = next;
}
input.addEventListener('input', change);
return function () { input.removeEventListener('input', change); };
}
document.querySelectorAll('.field-ink').forEach(initFieldInk);Goal: Create a "Ink" input.
Visual behavior: A dedication card where every letter bleeds onto the paper like ink: it lands blurred and slightly large, sharpens as it dries, and leaves a small blot spreading out beneath it.
Technique:
- The real input keeps transparent text and a visible caret; an aria-hidden mirror with the same font, size and padding (and font-kerning: none) draws the characters on top, so the caret always lines up.
- New letters run a keyframe from blur(4px) and scale(1.3) to sharp; a ::before radial blot under each letter scales from 0.3 to 1.9 while it fades.
- Edits are diffed by common prefix and suffix, so only the characters actually typed are new; untouched characters keep their element and their animation state.
- An italic serif and a ruled line make it read as handwriting on a card.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. Under prefers-reduced-motion letters appear without the ink effect.
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 "Ink" input.
Visual behavior: A dedication card where every letter bleeds onto the paper like ink: it lands blurred and slightly large, sharpens as it dries, and leaves a small blot spreading out beneath it.
Technique:
- The real input keeps transparent text and a visible caret; an aria-hidden mirror with the same font, size and padding (and font-kerning: none) draws the characters on top, so the caret always lines up.
- New letters run a keyframe from blur(4px) and scale(1.3) to sharp; a ::before radial blot under each letter scales from 0.3 to 1.9 while it fades.
- Edits are diffed by common prefix and suffix, so only the characters actually typed are new; untouched characters keep their element and their animation state.
- An italic serif and a ruled line make it read as handwriting on a card.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. Under prefers-reduced-motion letters appear without the ink effect.
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 card where every letter bleeds onto the paper like ink: it lands blurred and slightly large, sharpens as it dries, and leaves a small blot spreading out beneath it.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change
- Techniques
- keyframes, filter, pseudo elements
- 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 letters appear without the ink effect.