Skip to content

Instantly share code, notes, and snippets.

@moshfeu
Last active June 4, 2026 09:04
Show Gist options
  • Select an option

  • Save moshfeu/ac894b77d822a00be5c376e34cbf7295 to your computer and use it in GitHub Desktop.

Select an option

Save moshfeu/ac894b77d822a00be5c376e34cbf7295 to your computer and use it in GitHub Desktop.
Textarea autogrow transition demo
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Auto-growing textarea</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f5f5;
padding: 16px;
margin: 0;
}
.field {
display: flex;
align-items: flex-start;
gap: 6px;
max-width: 351px;
margin: 0 auto;
padding: 14px 12px;
background: #fff;
border: 1px solid #a8a6a5;
border-radius: 12px;
overflow: hidden;
box-sizing: border-box;
}
.field__input {
flex: 1;
min-width: 0;
height: 30px; /* one line; JS overrides as content grows */
padding: 0;
border: none;
outline: none;
resize: none;
overflow-y: hidden;
background: transparent;
font-family: inherit;
font-size: 16px;
line-height: 30px;
transition: height 0.15s ease-out;
}
.field__submit {
flex-shrink: 0;
width: 30px;
height: 30px;
border: none;
border-radius: 50%;
background: #000;
color: #fff;
font-size: 16px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="field">
<textarea
class="field__input"
rows="1"
placeholder="Describe your business…"
aria-label="Describe your business"
></textarea>
<button class="field__submit" aria-label="Generate">✦</button>
</div>
<script>
const LINE_HEIGHT = 30;
const MAX_LINES = 5;
// Keep slightly above the CSS transition duration so scrollTop stays pinned
// for the whole animation.
const TRANSITION_MS = 150;
const PIN_DURATION_MS = TRANSITION_MS + 50;
/**
* Measure the natural content height of a textarea without touching it —
* uses an off-screen clone with matching width/typography.
*/
function measureContentHeight(textarea) {
const cs = getComputedStyle(textarea);
const mirror = document.createElement('textarea');
mirror.rows = 1; // avoid the browser's default 2-row minimum
mirror.style.cssText = [
'position:fixed',
'top:0',
'left:0',
'visibility:hidden',
'pointer-events:none',
'height:0',
'padding:0',
'border:none',
'resize:none',
'overflow:hidden',
`width:${cs.width}`,
`font-family:${cs.fontFamily}`,
`font-size:${cs.fontSize}`,
`line-height:${cs.lineHeight}`,
`letter-spacing:${cs.letterSpacing}`,
].join(';');
mirror.value = textarea.value;
document.body.appendChild(mirror);
const height = mirror.scrollHeight;
document.body.removeChild(mirror);
return height;
}
function setupAutoGrow(textarea) {
const maxHeight = LINE_HEIGHT * MAX_LINES;
let pinUntil = 0;
let pinning = false;
// While the height transition runs, the browser keeps auto-scrolling the
// textarea to reveal the caret (the box is briefly shorter than its
// content). Pin scrollTop to 0 every frame for the transition's duration so
// the first line stays anchored and the text never jumps.
function pinScrollTop() {
textarea.scrollTop = 0;
if (performance.now() < pinUntil) {
requestAnimationFrame(pinScrollTop);
} else {
pinning = false;
}
}
function update() {
const contentHeight = measureContentHeight(textarea);
const targetHeight = Math.max(
LINE_HEIGHT,
Math.min(contentHeight, maxHeight),
);
textarea.style.height = `${targetHeight}px`;
textarea.style.overflowY = contentHeight > maxHeight ? 'auto' : 'hidden';
textarea.scrollTop = 0;
pinUntil = performance.now() + PIN_DURATION_MS;
if (!pinning) {
pinning = true;
requestAnimationFrame(pinScrollTop);
}
}
textarea.addEventListener('input', update);
update();
}
document.querySelectorAll('.field__input').forEach(setupAutoGrow);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment