Last active
June 4, 2026 09:04
-
-
Save moshfeu/ac894b77d822a00be5c376e34cbf7295 to your computer and use it in GitHub Desktop.
Textarea autogrow transition demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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