Input

input.svelte

A styled monospace text input field with custom caret display.

Demo

Enter your text here...
Sample Text
No border
Usage
001 <script lang="ts">
002 import Input from '$lib/components/input.svelte';
003 </script>
004
005 <div class="flex flex-col gap-2">
006 <label for="input-empty">EMPTY CASE</label>
007 <Input id="input-empty" bordered />
008
009 <label for="input-placeholder">PLACEHOLDER</label>
010 <Input id="input-placeholder" bordered placeholder="Enter your text here..." />
011
012 <label for="input-value">VALUE</label>
013 <Input id="input-value" bordered value="Sample Text" />
014
015 <label for="input-password">PASSWORD</label>
016 <Input id="input-password" bordered type="password" />
017
018 <label for="input-caret">CUSTOM CARET</label>
019 <Input id="input-caret" bordered caret="$" />
020
021 <label for="input-bare">BORDERLESS (DEFAULT)</label>
022 <Input id="input-bare" value="No border" />
023 </div>
024
input.svelte
001 <script lang="ts" module>
002 import type { HTMLInputAttributes } from 'svelte/elements';
003
004 export type InputProps = Omit<HTMLInputAttributes, 'type'> & {
005 ref?: HTMLInputElement | null;
006 caret?: string;
007 type?: 'text' | 'password';
008 bordered?: boolean;
009 };
010 </script>
011
012 <script lang="ts">
013 let {
014 class: className,
015 ref = $bindable(null),
016 caret = '',
017 bordered = false,
018 autofocus = false,
019 value = $bindable(''),
020 type = $bindable('text'),
021 placeholder = $bindable(''),
022 disabled = $bindable(false),
023 onkeydown,
024 ...restProps
025 }: InputProps = $props();
026
027 let selectionStart = $state(0);
028 let focused = $state(false);
029
030 const fallbackId = `input-${Math.random().toString(36).substring(2, 15)}`;
031 const inputId = $derived(restProps.id ?? fallbackId);
032
033 let inputEl: HTMLInputElement;
034
035 $effect(() => {
036 ref = inputEl;
037 if (autofocus && inputEl) {
038 inputEl.focus();
039 focused = true;
040 }
041 });
042
043 const displayStr = $derived.by(() => {
044 const str = String(value ?? '');
045 return type === 'password' ? '•'.repeat(str.length) : str;
046 });
047
048 const beforeCaret = $derived(displayStr.slice(0, selectionStart));
049 const caretChar = $derived(caret || (displayStr[selectionStart] ?? ' '));
050 const afterCaret = $derived(
051 displayStr.length > selectionStart ? displayStr.slice(selectionStart + 1) : ''
052 );
053
054 const syncSelection = () => {
055 if (inputEl && inputEl.selectionStart !== null) {
056 selectionStart = inputEl.selectionStart;
057 }
058 };
059
060 const onInput = (e: Event) => {
061 const input = e.target as HTMLInputElement;
062 value = input.value;
063 selectionStart = input.selectionStart ?? input.value.length;
064 };
065
066 const onFocus = () => {
067 focused = true;
068 syncSelection();
069 };
070
071 const onBlur = () => {
072 focused = false;
073 };
074 </script>
075
076 <div
077 class={`displayed ${className ?? ''}`}
078 class:focused
079 class:bordered
080 role="textbox"
081 tabindex="-1"
082 >
083 {#if !focused}
084 {#if !value && placeholder}
085 <span class="placeholder">{placeholder}</span>
086 {:else}
087 <span class="text-slice">{displayStr}</span>
088 {/if}
089 {:else if !value && placeholder}
090 {#if !disabled}<span class="caret blink">{caret || ' '}</span>{/if}<span class="placeholder"
091 >{placeholder}</span
092 >
093 {:else}
094 <span class="text-slice">{beforeCaret}</span>{#if !disabled}<span class="caret blink"
095 >{caretChar}</span
096 >{/if}<span class="text-slice">{afterCaret}</span>
097 {/if}
098
099 <input
100 id={inputId}
101 bind:this={inputEl}
102 {type}
103 bind:value
104 class="native-input"
105 oninput={onInput}
106 onfocus={onFocus}
107 onblur={onBlur}
108 onselect={syncSelection}
109 onselectionchange={syncSelection}
110 onkeyup={syncSelection}
111 onclick={syncSelection}
112 {onkeydown}
113 {disabled}
114 {...restProps}
115 />
116 </div>
117
118 <style>
119 .displayed {
120 background: var(--surface-base);
121 color: var(--text-primary);
122 cursor: text;
123 white-space: pre;
124 position: relative;
125 display: flex;
126 align-items: center;
127 padding: 0 1ch;
128 height: var(--line);
129 font-family: var(--font-family-mono);
130 font-size: var(--font-size);
131 line-height: var(--line);
132 box-sizing: border-box;
133 overflow: hidden;
134 }
135
136 .displayed.bordered {
137 box-shadow: inset 0 0 0 1px var(--border-default);
138 }
139
140 .displayed.bordered.focused {
141 box-shadow: inset 0 0 0 2px var(--focus-ring);
142 }
143
144 .placeholder {
145 color: var(--button-muted);
146 font-style: italic;
147 pointer-events: none;
148 user-select: none;
149 }
150
151 .text-slice {
152 pointer-events: none;
153 user-select: none;
154 }
155
156 .caret {
157 display: inline-block;
158 min-width: 1ch;
159 height: var(--line);
160 line-height: var(--line);
161 vertical-align: middle;
162 pointer-events: none;
163 user-select: none;
164 background: var(--text-primary);
165 color: var(--surface-base);
166 }
167
168 .caret.blink {
169 animation: blink 1s step-start infinite;
170 }
171
172 .native-input {
173 position: absolute;
174 inset: 0;
175 width: 100%;
176 height: 100%;
177 opacity: 0;
178 cursor: text;
179 margin: 0;
180 padding: 0 1ch;
181 border: 0;
182 outline: 0;
183 background: transparent;
184 color: transparent;
185 caret-color: transparent;
186 font-family: inherit;
187 font-size: inherit;
188 line-height: inherit;
189 }
190
191 @keyframes blink {
192 50% {
193 background: transparent;
194 color: inherit;
195 }
196 }
197 </style>
198