Overflow
import { useId, useState } from 'react';
const LIMIT = 24;
const css = `
.field-overflow { width: 260px; }
.field-overflow__top { display: flex; justify-content: space-between; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-overflow__count { color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; transition: color 0.2s; }
.field-overflow[data-over] .field-overflow__count { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.field-overflow__box { position: relative; height: 44px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); transition: box-shadow 0.2s; }
.field-overflow__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2), 0 0 0 3px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent); }
.field-overflow__input, .field-overflow__mirror { padding: 0 12px; font: 400 13px/44px ui-monospace, SFMono-Regular, Menlo, monospace; }
.field-overflow__input { width: 100%; height: 100%; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#373a40, #f1f2f3); }
.field-overflow__input::placeholder { color: light-dark(#6d7179, #7d8087); }
.field-overflow__mirror { position: absolute; inset: 0; overflow: visible; color: light-dark(#373a40, #f1f2f3); white-space: pre; pointer-events: none; }
/* Past the limit each letter sags a little more than the last, as if it is spilling off the end. */
.field-overflow__spill { display: inline-block; color: light-dark(#5f626a, #a7abb2); text-decoration: line-through; text-decoration-color: color-mix(in oklab, light-dark(#5f626a, #a7abb2) 70%, transparent); transform: translateY(calc(var(--k) * var(--k) * 0.5px)) rotate(calc(var(--k) * 4deg)); transform-origin: 0 50%; transition: transform 0.4s cubic-bezier(0.3, 1.6, 0.5, 1); }
.field-overflow__edge { font: 400 13px ui-monospace, SFMono-Regular, Menlo, monospace; position: absolute; top: 8px; bottom: 8px; width: 1px; background: light-dark(#cbced3, #393c41); opacity: 0; transition: opacity 0.2s; }
.field-overflow[data-over] .field-overflow__edge { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.field-overflow__spill { transition: none; }
}
`;
export function OverflowInput() {
const id = useId();
const [v, setV] = useState('');
const left = LIMIT - v.length;
const over = left < 0;
return (
<>
<style>
{css}
</style>
<div className="field-overflow" data-over={over || undefined}>
<div className="field-overflow__top">
<label htmlFor={id}>Bio</label>
<span id={`${id}-n`} className="field-overflow__count" aria-live="polite">
{over ? `${-left} too many` : left}
</span>
</div>
<div className="field-overflow__box">
<input
id={id}
className="field-overflow__input"
value={v}
maxLength={30}
placeholder="Designer, maker of tea"
aria-describedby={`${id}-n`}
aria-invalid={over}
autoComplete="off"
spellCheck={false}
onChange={(e) => setV(e.target.value)}
/>
<span className="field-overflow__mirror" aria-hidden="true">
{v.slice(0, LIMIT)}
{[...v.slice(LIMIT)].map((c, k) => (
<span key={k} className="field-overflow__spill" style={{ '--k': k + 1 } as React.CSSProperties}>
{c}
</span>
))}
</span>
{/* The limit, drawn where the 24th character ends: monospace, so it is a fixed distance. */}
<span className="field-overflow__edge" aria-hidden="true" style={{ left: `calc(12px + ${LIMIT}ch)` }} />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldOverflow(el) on the mounted element instead.
<div class="field-overflow">
<div class="field-overflow__top">
<label for="field-overflow">Bio</label>
<span id="field-overflow-n" class="field-overflow__count" aria-live="polite">24</span>
</div>
<div class="field-overflow__box">
<input id="field-overflow" class="field-overflow__input" maxLength="30" placeholder="Designer, maker of tea" aria-describedby="field-overflow-n" aria-invalid="false" autoComplete="off" spellCheck="false" value=""/>
<span class="field-overflow__mirror" aria-hidden="true"></span>
<span class="field-overflow__edge" aria-hidden="true" style="left:calc(12px + 24ch)"></span>
</div>
</div>.field-overflow { width: 260px; }
.field-overflow__top { display: flex; justify-content: space-between; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-overflow__count { color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; transition: color 0.2s; }
.field-overflow[data-over] .field-overflow__count { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.field-overflow__box { position: relative; height: 44px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); transition: box-shadow 0.2s; }
.field-overflow__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2), 0 0 0 3px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent); }
.field-overflow__input, .field-overflow__mirror { padding: 0 12px; font: 400 13px/44px ui-monospace, SFMono-Regular, Menlo, monospace; }
.field-overflow__input { width: 100%; height: 100%; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#373a40, #f1f2f3); }
.field-overflow__input::placeholder { color: light-dark(#6d7179, #7d8087); }
.field-overflow__mirror { position: absolute; inset: 0; overflow: visible; color: light-dark(#373a40, #f1f2f3); white-space: pre; pointer-events: none; }
/* Past the limit each letter sags a little more than the last, as if it is spilling off the end. */
.field-overflow__spill { display: inline-block; color: light-dark(#5f626a, #a7abb2); text-decoration: line-through; text-decoration-color: color-mix(in oklab, light-dark(#5f626a, #a7abb2) 70%, transparent); transform: translateY(calc(var(--k) * var(--k) * 0.5px)) rotate(calc(var(--k) * 4deg)); transform-origin: 0 50%; transition: transform 0.4s cubic-bezier(0.3, 1.6, 0.5, 1); }
.field-overflow__edge { font: 400 13px ui-monospace, SFMono-Regular, Menlo, monospace; position: absolute; top: 8px; bottom: 8px; width: 1px; background: light-dark(#cbced3, #393c41); opacity: 0; transition: opacity 0.2s; }
.field-overflow[data-over] .field-overflow__edge { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.field-overflow__spill { transition: none; }
}function initFieldOverflow(root) {
var LIMIT = 24;
var input = root.querySelector('.field-overflow__input');
var count = root.querySelector('.field-overflow__count');
var mirror = root.querySelector('.field-overflow__mirror');
function update() {
var v = input.value;
var left = LIMIT - v.length;
var over = left < 0;
root.toggleAttribute('data-over', over);
input.setAttribute('aria-invalid', String(over));
count.textContent = over ? -left + ' too many' : left;
// Past the limit each letter is its own span, and sags a little more than the last.
mirror.textContent = v.slice(0, LIMIT);
Array.prototype.forEach.call(v.slice(LIMIT), function (c, k) {
var el = document.createElement('span');
el.className = 'field-overflow__spill';
el.style.setProperty('--k', k + 1);
el.textContent = c;
mirror.appendChild(el);
});
}
input.addEventListener('input', update);
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.field-overflow').forEach(initFieldOverflow);Goal: Create a "Overflow" input.
Visual behavior: A short bio field with a limit: letters past it sag off the end of the line, each a little lower than the last, struck through as if spilling over, and the count turns into how many too many.
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.
- The limit is drawn as a line where the 24th character ends (the font is monospace, so it is a fixed distance); every character past it is its own span with an index (--k) that sets how far it drops and tilts.
- The count says how many are left and, past the limit, how many too many; aria-invalid is set to match.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The count is aria-live and linked by aria-describedby, and going over sets aria-invalid. Under prefers-reduced-motion the letters sag without easing.
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 "Overflow" input.
Visual behavior: A short bio field with a limit: letters past it sag off the end of the line, each a little lower than the last, struck through as if spilling over, and the count turns into how many too many.
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.
- The limit is drawn as a line where the 24th character ends (the font is monospace, so it is a fixed distance); every character past it is its own span with an index (--k) that sets how far it drops and tilts.
- The count says how many are left and, past the limit, how many too many; aria-invalid is set to match.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The count is aria-live and linked by aria-describedby, and going over sets aria-invalid. Under prefers-reduced-motion the letters sag without easing.
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 short bio field with a limit: letters past it sag off the end of the line, each a little lower than the last, struck through as if spilling over, and the count turns into how many too many.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change
- Techniques
- css variables, css transition, text animation
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. The count is aria-live and linked by aria-describedby, and going over sets aria-invalid. Under prefers-reduced-motion the letters sag without easing.