Text Area
text-area.svelteA themed auto-growing textarea with a fake-caret display.
Demo
Type something…
Usage
001 <script lang="ts">
002 import TextArea from '$lib/components/text-area.svelte';
003 </script>
004
005 <div class="flex flex-col gap-2 w-full">
006 <TextArea placeholder="Type something…" />
007 <TextArea autoPlay="#!/bin/sh\necho hello, world\n" autoPlaySpeedMS={60} />
008 </div>
009
text-area.svelte
001 <script lang="ts" module>
002 import type { HTMLTextareaAttributes } from 'svelte/elements';
003
004 /** A themed, auto-growing textarea with a fake-caret display mirror and an
005 * optional `autoPlay` string that types itself out letter by letter. */
006 export type TextAreaProps = HTMLTextareaAttributes & {
007 ref?: HTMLTextAreaElement | null;
008 autoPlay?: string;
009 autoPlaySpeedMS?: number;
010 };
011 </script>
012
013 <script lang="ts">
014 let {
015 class: className,
016 ref = $bindable(null),
017 autoPlay,
018 autoPlaySpeedMS = 40,
019 value = $bindable(undefined),
020 defaultValue,
021 placeholder,
022 onchange,
023 ...restProps
024 }: TextAreaProps = $props();
025
026 let textAreaEl = $state<HTMLTextAreaElement>(null!);
027 let measureEl = $state<HTMLDivElement>(null!);
028
029 // svelte-ignore state_referenced_locally
030 let text = $state(String(defaultValue ?? value ?? ''));
031 let isFocused = $state(false);
032 let selectionStart = $state(0);
033 let currentLineIndex = $state(0);
034 let totalLines = $state(0);
035
036 // Keep `ref` in sync with the actual textarea.
037 $effect(() => {
038 ref = textAreaEl;
039 });
040
041 // Restore caret when refocusing / when selection moves while focused.
042 $effect(() => {
043 if (textAreaEl && isFocused) {
044 textAreaEl.setSelectionRange(selectionStart, selectionStart);
045 }
046 });
047
048 // Controlled value sync.
049 $effect(() => {
050 if (value !== undefined) {
051 const v = String(value);
052 text = v;
053 selectionStart = v.length;
054 }
055 });
056
057 // Auto-type the `autoPlay` string out letter by letter.
058 $effect(() => {
059 if (!autoPlay || value !== undefined || defaultValue !== undefined) return;
060 let i = 0;
061 text = '';
062 const id = setInterval(() => {
063 i++;
064 if (i > autoPlay.length) {
065 clearInterval(id);
066 return;
067 }
068 const next = autoPlay.slice(0, i);
069 text = next;
070 selectionStart = next.length;
071 }, autoPlaySpeedMS);
072 return () => clearInterval(id);
073 });
074
075 // Auto-size the textarea to its content. Re-runs whenever `text` changes
076 // (reactive read) and on window resize.
077 $effect(() => {
078 // reactive dep: re-run when text changes
079 void text;
080 if (!textAreaEl) return;
081 textAreaEl.style.height = 'auto';
082 textAreaEl.style.height = `${textAreaEl.scrollHeight}px`;
083 });
084
085 $effect(() => {
086 const onResize = () => {
087 if (!textAreaEl) return;
088 textAreaEl.style.height = 'auto';
089 textAreaEl.style.height = `${textAreaEl.scrollHeight}px`;
090 };
091 window.addEventListener('resize', onResize);
092 return () => window.removeEventListener('resize', onResize);
093 });
094
095 // Count rendered lines so arrow-up/down can jump out of the field at the edges.
096 $effect(() => {
097 if (!measureEl) return;
098 const computed = window.getComputedStyle(measureEl);
099 const lh = parseFloat(computed.lineHeight) || 20;
100 const countLines = (content: string) => {
101 // eslint-disable-next-line svelte/no-dom-manipulating -- measurement only, never read by the view
102 measureEl.textContent = content || '\u00A0';
103 return Math.round(measureEl.offsetHeight / lh);
104 };
105 const display = text || placeholder || '';
106 const before = text.substring(0, selectionStart);
107 const total = countLines(display);
108 totalLines = total > 0 ? total - 1 : 0;
109 const cur = countLines(before);
110 currentLineIndex = cur > 0 ? cur - 1 : 0;
111 });
112
113 const isFocusable = (el: Element): el is HTMLElement =>
114 el.matches(
115 'a[href], button:not([disabled]), input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])'
116 );
117 const moveFocus = (dir: 'next' | 'prev') => {
118 const els = Array.from(document.querySelectorAll('*')).filter(isFocusable);
119 const i = els.indexOf(document.activeElement as HTMLElement);
120 if (i === -1) return;
121 const target = dir === 'next' ? els[i + 1] : els[i - 1];
122 target?.focus();
123 };
124
125 const onInput = (e: Event) => {
126 const ta = e.currentTarget as HTMLTextAreaElement;
127 text = ta.value;
128 value = ta.value;
129 selectionStart = ta.selectionStart ?? 0;
130 };
131
132 const onSelect = (e: Event) => {
133 selectionStart = (e.currentTarget as HTMLTextAreaElement).selectionStart;
134 };
135 const onFocus = () => {
136 isFocused = true;
137 if (textAreaEl) selectionStart = textAreaEl.selectionStart;
138 };
139 const onBlur = () => (isFocused = false);
140 const onClick = (e: MouseEvent) => {
141 const ta = e.currentTarget as HTMLTextAreaElement;
142 ta.focus();
143 selectionStart = ta.selectionStart;
144 };
145 const onKeyDown = (e: KeyboardEvent) => {
146 if (e.key === 'ArrowUp' && currentLineIndex === 0) {
147 e.preventDefault();
148 moveFocus('prev');
149 } else if (e.key === 'ArrowDown' && currentLineIndex === totalLines) {
150 e.preventDefault();
151 moveFocus('next');
152 }
153 };
154
155 const isPlaceholderVisible = $derived(!text && !!placeholder);
156 const beforeCaret = $derived(text.substring(0, selectionStart));
157 const afterCaret = $derived(text.substring(selectionStart));
158 </script>
159
160 <div class={`root ${className ?? ''}`} class:focused={isFocused}>
161 <div class="displayed {isPlaceholderVisible ? 'placeholder' : ''}">
162 {#if isPlaceholderVisible}
163 {placeholder}<span class="block {isFocused ? 'blink' : ''}"></span>
164 {:else}
165 {beforeCaret}<span class="block {isFocused ? 'blink' : ''}"></span>{afterCaret}
166 {/if}
167 </div>
168 <div bind:this={measureEl} class="hidden"></div>
169 <textarea
170 bind:this={textAreaEl}
171 class="hidden-element"
172 value={text}
173 {placeholder}
174 oninput={onInput}
175 onselect={onSelect}
176 onfocus={onFocus}
177 onblur={onBlur}
178 onclick={onClick}
179 onkeydown={onKeyDown}
180 {onchange}
181 {...restProps}
182 ></textarea>
183 </div>
184
185 <style>
186 .root {
187 display: block;
188 position: relative;
189 }
190
191 .displayed {
192 white-space: pre-wrap;
193 word-wrap: break-word;
194 overflow-wrap: anywhere;
195 pointer-events: none;
196 min-height: var(--line);
197 }
198
199 .displayed.placeholder {
200 opacity: 0.7;
201 font-style: italic;
202 }
203
204 .block {
205 display: inline-block;
206 min-width: 1ch;
207 height: var(--line);
208 vertical-align: bottom;
209 background: var(--text-primary);
210 }
211
212 .block.blink {
213 animation: svtui-blink 1s step-start 0s infinite;
214 }
215
216 .focused .block {
217 background: var(--focus-ring);
218 }
219
220 .hidden {
221 white-space: pre-wrap;
222 word-wrap: break-word;
223 overflow-wrap: anywhere;
224 pointer-events: none;
225 position: absolute;
226 visibility: hidden;
227 width: 100%;
228 overflow: auto;
229 }
230
231 .hidden-element {
232 position: absolute;
233 top: 0;
234 left: 0;
235 width: 100%;
236 height: 100%;
237 background: transparent;
238 color: transparent;
239 caret-color: transparent;
240 border: none;
241 outline: none;
242 margin: 0;
243 padding: 0;
244 resize: none;
245 overflow: hidden;
246 line-height: var(--line);
247 font-size: var(--font-size);
248 font-family: inherit;
249 }
250
251 @keyframes svtui-blink {
252 50% {
253 opacity: 0;
254 }
255 }
256 </style>
257