Grow
import { useState } from 'react';
const css = `
@keyframes field-grow-num { from { opacity: 0; transform: translateY(-6px); } }
.field-grow { width: 250px; }
.field-grow__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-grow__box {
display: flex;
border-radius: 10px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
/* The height follows the lines with a little spring. */
transition: height 0.38s cubic-bezier(0.34, 1.4, 0.64, 1), box-shadow 0.2s;
}
.field-grow__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-grow__gutter { flex: none; width: 28px; padding: 8px 0; background: light-dark(#f3f4f7, #1a1c1f); color: light-dark(#6d7179, #7d8087); font: 12px/18px ui-monospace, SFMono-Regular, Menlo, monospace; text-align: right; overflow: hidden; }
.field-grow__gutter span { display: block; padding-right: 7px; animation: field-grow-num 0.25s ease-out; }
.field-grow__gutter span[data-cur] { color: light-dark(#42454c, #e5e6e9); }
.field-grow__area {
flex: 1;
min-width: 0;
padding: 8px 10px;
border: 0;
outline: 0;
resize: none;
background: none;
color: light-dark(#373a40, #f1f2f3);
font: 12px/18px ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre;
overflow-x: auto;
overflow-y: hidden;
}
.field-grow__area[data-scroll] { overflow-y: auto; }
.field-grow__foot { display: flex; justify-content: space-between; margin-top: 6px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) { .field-grow__box { transition: box-shadow 0.2s; } .field-grow__gutter span { animation: none; } }
`;
const LINE = 18;
const MIN = 2;
const MAX = 7;
export function GrowInput() {
const [value, setValue] = useState("const ease = 'out';\nconst ms = 240;");
const [line, setLine] = useState(1);
const lines = value.split('\n').length;
const rows = Math.max(MIN, Math.min(MAX, lines));
const caretLine = (el: HTMLTextAreaElement) => setLine(el.value.slice(0, el.selectionStart).split('\n').length);
return (
<>
<style>
{css}
</style>
<div className="field-grow">
<label className="field-grow__label" htmlFor="field-grow-area">
Snippet
</label>
<div className="field-grow__box" style={{ height: rows * LINE + 16 }}>
<div className="field-grow__gutter" aria-hidden="true">
{Array.from({ length: lines }, (_, i) => (
<span key={i} data-cur={i + 1 === line || undefined}>
{i + 1}
</span>
))}
</div>
<textarea
id="field-grow-area"
className="field-grow__area"
value={value}
rows={rows}
spellCheck={false}
data-scroll={lines > MAX || undefined}
onChange={(e) => {
setValue(e.target.value);
caretLine(e.target);
}}
onSelect={(e) => caretLine(e.currentTarget)}
onScroll={(e) => {
const g = e.currentTarget.previousElementSibling as HTMLElement;
g.scrollTop = e.currentTarget.scrollTop;
}}
/>
</div>
<div className="field-grow__foot">
<span>
Ln {line}, {lines} line{lines === 1 ? '' : 's'}
</span>
<span>{value.length} chars</span>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldGrow(el) on the mounted element instead.
<div class="field-grow">
<label class="field-grow__label" for="field-grow-area">Snippet</label>
<div class="field-grow__box" style="height:52px">
<div class="field-grow__gutter" aria-hidden="true">
<span data-cur="true">1</span>
<span>2</span>
</div>
<textarea id="field-grow-area" class="field-grow__area" rows="2" spellCheck="false">const ease = 'out';
const ms = 240;</textarea>
</div>
<div class="field-grow__foot">
<span>Ln 1, 2 lines</span>
<span>35 chars</span>
</div>
</div>@keyframes field-grow-num { from { opacity: 0; transform: translateY(-6px); } }
.field-grow { width: 250px; }
.field-grow__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-grow__box {
display: flex;
border-radius: 10px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
/* The height follows the lines with a little spring. */
transition: height 0.38s cubic-bezier(0.34, 1.4, 0.64, 1), box-shadow 0.2s;
}
.field-grow__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-grow__gutter { flex: none; width: 28px; padding: 8px 0; background: light-dark(#f3f4f7, #1a1c1f); color: light-dark(#6d7179, #7d8087); font: 12px/18px ui-monospace, SFMono-Regular, Menlo, monospace; text-align: right; overflow: hidden; }
.field-grow__gutter span { display: block; padding-right: 7px; animation: field-grow-num 0.25s ease-out; }
.field-grow__gutter span[data-cur] { color: light-dark(#42454c, #e5e6e9); }
.field-grow__area {
flex: 1;
min-width: 0;
padding: 8px 10px;
border: 0;
outline: 0;
resize: none;
background: none;
color: light-dark(#373a40, #f1f2f3);
font: 12px/18px ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre;
overflow-x: auto;
overflow-y: hidden;
}
.field-grow__area[data-scroll] { overflow-y: auto; }
.field-grow__foot { display: flex; justify-content: space-between; margin-top: 6px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) { .field-grow__box { transition: box-shadow 0.2s; } .field-grow__gutter span { animation: none; } }function initFieldGrow(root) {
var box = root.querySelector('.field-grow__box');
var gutter = root.querySelector('.field-grow__gutter');
var area = root.querySelector('.field-grow__area');
var foot = root.querySelectorAll('.field-grow__foot span');
if (!box || !gutter || !area || foot.length !== 2) return;
var LINE = 18;
var MIN = 2;
var MAX = 7;
var line = 1;
function render() {
var value = area.value;
var lines = value.split('\n').length;
var rows = Math.max(MIN, Math.min(MAX, lines));
box.style.height = rows * LINE + 16 + 'px';
area.rows = rows;
area.toggleAttribute('data-scroll', lines > MAX);
// Keep existing numbers; only new ones slide in.
while (gutter.children.length > lines) gutter.lastChild.remove();
while (gutter.children.length < lines) {
var n = document.createElement('span');
n.textContent = String(gutter.children.length + 1);
gutter.appendChild(n);
}
Array.prototype.forEach.call(gutter.children, function (n, i) { n.toggleAttribute('data-cur', i + 1 === line); });
foot[0].textContent = 'Ln ' + line + ', ' + lines + ' line' + (lines === 1 ? '' : 's');
foot[1].textContent = value.length + ' chars';
}
function caret() {
line = area.value.slice(0, area.selectionStart).split('\n').length;
render();
}
function scroll() { gutter.scrollTop = area.scrollTop; }
area.addEventListener('input', caret);
area.addEventListener('select', caret);
area.addEventListener('click', caret);
area.addEventListener('keyup', caret);
area.addEventListener('scroll', scroll);
render();
return function () {
area.removeEventListener('input', caret);
area.removeEventListener('select', caret);
area.removeEventListener('click', caret);
area.removeEventListener('keyup', caret);
area.removeEventListener('scroll', scroll);
};
}
document.querySelectorAll('.field-grow').forEach(initFieldGrow);Goal: Create a "Grow" input.
Visual behavior: A code-snippet field that grows line by line: the box springs taller as you add lines (up to seven, then scrolls), line numbers slide into the gutter, the current line's number lights and the footer tracks line and character counts.
Technique:
- The box height is rows x 18px + padding, with rows clamped between 2 and 7; it transitions on an overshooting curve, so it springs to each new size.
- The textarea uses white-space: pre so hard lines are the only lines, which keeps the gutter numbers exact; new numbers slide in on a keyframe.
- The caret's line comes from counting newlines before selectionStart; the gutter scrolls with the textarea once it overflows.
Accessibility: A real <textarea> with a <label>; the gutter is aria-hidden. Under prefers-reduced-motion the height changes without the spring.
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 "Grow" input.
Visual behavior: A code-snippet field that grows line by line: the box springs taller as you add lines (up to seven, then scrolls), line numbers slide into the gutter, the current line's number lights and the footer tracks line and character counts.
Technique:
- The box height is rows x 18px + padding, with rows clamped between 2 and 7; it transitions on an overshooting curve, so it springs to each new size.
- The textarea uses white-space: pre so hard lines are the only lines, which keeps the gutter numbers exact; new numbers slide in on a keyframe.
- The caret's line comes from counting newlines before selectionStart; the gutter scrolls with the textarea once it overflows.
Accessibility: A real <textarea> with a <label>; the gutter is aria-hidden. Under prefers-reduced-motion the height changes without the spring.
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 code-snippet field that grows line by line: the box springs taller as you add lines (up to seven, then scrolls), line numbers slide into the gutter, the current line's number lights and the footer tracks line and character counts.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, spring, state change
- Techniques
- css transition, keyframes, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <textarea> with a <label>; the gutter is aria-hidden. Under prefers-reduced-motion the height changes without the spring.