Inline editable text
Shows a saved value as text with an Edit button; editing swaps in an input or textarea in the same box with explicit Save and Cancel. An async onSave that can fail keeps the draft, and blur never saves.
cmp_inline_edit_text_01 Preview
Demonstration — no data is sent
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_inline_edit_text_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-c34eaa9a479a56b8b45462b3197c49c72e688f983f26e109eecd04e65da3fa98
<!--
Inline editable text: a saved value shown as text beside an Edit button. Edit swaps the text
for an input (or a textarea) in the same box, with the value at the same position, and
explicit Save and Cancel. Save runs local validation, then the consumer's async onSave, which
may fail; a failure keeps the draft and shows the message. Blur never saves. Enter saves a
single line, Ctrl or Cmd+Enter saves multiline, Escape cancels, and focus returns to the Edit
button afterwards.
-->
<script lang="ts" module>
export type InlineEditTextSaveResult =
{ ok: true; value?: string } | { ok: false; message: string };
export type InlineEditTextActions = 'end' | 'below';
export type InlineEditTextEditButton = 'icon' | 'text';
</script>
<script lang="ts">
import { tick, type Snippet } from 'svelte';
interface Props {
/** Current saved value. Bindable: a successful save writes the saved value back. */
value: string;
/** What is being edited, e.g. 'Project name'. Labels the field and names the Edit button. */
label: string;
/** Persists the change. Return { ok: true, value? } (value replaces the display) or { ok: false, message }. */
onSave: (next: string) => Promise<InlineEditTextSaveResult>;
/** Edit in a textarea; Enter adds a line and Ctrl or Cmd+Enter saves. */
multiline?: boolean;
/** Local check before onSave. Return a message to block the save, or null. */
validate?: (next: string) => string | null;
/** Shown in place of an empty value, e.g. 'Add a description'. */
emptyText?: string;
/** Custom rendering of the saved value, e.g. as a heading. */
display?: Snippet<[string]>;
/** Placeholder inside the empty field. Never a substitute for the label. */
placeholder?: string;
/** Save button text. */
saveLabel?: string;
/** Cancel button text. */
cancelLabel?: string;
/** Accessible name of the Edit button. Defaults to 'Edit' plus the label. */
editLabel?: string;
/** Visible text of the Edit button when editButton is 'text'. */
editText?: string;
/** A pencil icon button, or a text button with the pencil and editText. */
editButton?: InlineEditTextEditButton;
/** Save and Cancel beside the field, or below it. Defaults to below for multiline. */
actions?: InlineEditTextActions;
/** Keep the label for assistive technology only, when the page around it already says it. */
hideLabel?: boolean;
/** Show the value with no Edit button. Hiding the button is not authorisation. */
readonly?: boolean;
/** Bindable: whether the editor is open. Opening it this way does not move focus. */
editing?: boolean;
/** Announced while onSave runs. */
savingLabel?: string;
/** Announced after a save, while the Edit button shows a check for two seconds. */
savedLabel?: string;
/** Shown when onSave throws or fails without a message. */
errorMessage?: string;
/** Read before the error by screen readers only. */
errorPrefix?: string;
/** Extra classes on the root, e.g. a text size the display and the field share. */
class?: string;
}
let {
value = $bindable(),
label,
onSave,
multiline = false,
validate,
emptyText,
display,
placeholder,
saveLabel = 'Save',
cancelLabel = 'Cancel',
editLabel,
editText = 'Edit',
editButton = 'icon',
actions,
hideLabel = false,
readonly = false,
editing = $bindable(false),
savingLabel = 'Saving',
savedLabel = 'Saved',
errorMessage = "Couldn't save. Try again.",
errorPrefix = 'Error:',
class: className
}: Props = $props();
const uid = $props.id();
const labelId = `${uid}-label`;
const fieldId = `${uid}-field`;
const valueId = `${uid}-value`;
const errorId = `${uid}-error`;
/* editText plus the label, first letter lower-cased unless it starts an acronym (URL stays URL). */
const editName = $derived(
editLabel ??
`${editText} ${/^\p{Lu}\p{Ll}/u.test(label) ? label[0].toLowerCase() + label.slice(1) : label}`
);
const placement = $derived(actions ?? (multiline ? 'below' : 'end'));
const empty = $derived(!value);
let draft = $state(value ?? '');
let error = $state<string | null>(null);
let pending = $state(false);
let saved = $state(false);
let status = $state('');
let field: HTMLInputElement | HTMLTextAreaElement | undefined = $state();
let editTrigger: HTMLButtonElement | undefined = $state();
let savedTimer: ReturnType<typeof setTimeout> | undefined;
/* Opened from outside through bind:editing: start from the saved value, leave focus alone. */
let wasEditing = editing;
$effect.pre(() => {
if (editing !== wasEditing) session += 1;
if (editing && !wasEditing) {
draft = value ?? '';
error = null;
}
wasEditing = editing;
});
/* A save can resolve after the component is gone or the editor was reopened; both are checked. */
let disposed = false;
let session = 0;
$effect(() => () => {
disposed = true;
clearTimeout(savedTimer);
});
/* Clear, then set on the next tick, so a repeated message (a second Saved) is read again. */
async function announce(message: string) {
status = '';
await tick();
status = message;
}
async function startEdit() {
if (readonly || editing) return;
draft = value ?? '';
error = null;
saved = false;
status = '';
editing = true;
await tick();
if (!field) return;
field.focus();
/* A short name is usually replaced, so select it; a long text is usually amended. */
if (multiline) field.setSelectionRange(field.value.length, field.value.length);
else field.select();
}
async function close() {
editing = false;
error = null;
await tick();
editTrigger?.focus();
}
function cancel() {
if (pending) return;
draft = value ?? '';
status = '';
void close();
}
async function fail(message: string) {
error = message;
void announce(`${errorPrefix} ${message}`);
await tick();
field?.focus();
}
async function save() {
if (pending || !editing) return;
const next = draft;
const local = validate?.(next) ?? null;
if (local) return fail(local);
/* Nothing changed: close without a round trip. */
if (next === (value ?? '')) return close();
pending = true;
error = null;
void announce(`${label}: ${savingLabel}`);
const run = session;
let result: InlineEditTextSaveResult;
try {
result = await onSave(next);
} catch {
result = { ok: false, message: '' };
}
if (disposed) return;
pending = false;
if (result.ok) value = result.value ?? next;
/* The editor was closed or reopened meanwhile: keep the saved value, leave that editor be. */
if (run !== session) return;
if (!result.ok) return fail(result.message || errorMessage);
draft = value ?? '';
void announce(`${label}: ${savedLabel}`);
saved = true;
clearTimeout(savedTimer);
savedTimer = setTimeout(() => (saved = false), 2000);
await close();
}
/* keyCode 229: Safari can report the key that confirms an IME candidate as finished. */
const composing = (event: KeyboardEvent) => event.isComposing || event.keyCode === 229;
/* Enter saves from the field only, so Enter on Cancel still presses Cancel. */
function onkeydown(event: KeyboardEvent) {
if (composing(event)) return;
if (event.key === 'Enter' && (!multiline || event.metaKey || event.ctrlKey)) {
event.preventDefault();
void save();
}
}
/* Escape cancels from anywhere in the editor, Save and Cancel included. */
function onEditorKeydown(event: KeyboardEvent) {
if (composing(event) || event.key !== 'Escape') return;
event.preventDefault();
cancel();
}
function oninput() {
/* The message described the old draft; drop it once the draft changes. */
if (error) error = null;
}
/* A press on the text opens the editor too, unless it selected text or hit a link in it. */
function openFromDisplay(event: MouseEvent) {
if (readonly) return;
if ((event.target as Element).closest('a, button, input, textarea, select')) return;
if (window.getSelection()?.toString()) return;
void startEdit();
}
</script>
<div
class={['inline-edit-text min-w-0', className]}
data-editing={editing ? '' : undefined}
data-invalid={error ? '' : undefined}
>
{#if editing}
<label
id={labelId}
for={fieldId}
class={[
'mb-2 block text-[13px] leading-5 font-medium tracking-normal break-words text-(--_muted)',
hideLabel && 'sr-only'
]}>{label}</label
>
{:else}
<p
id={labelId}
class={[
'mb-2 text-[13px] leading-5 font-medium tracking-normal break-words text-(--_muted)',
hideLabel && 'sr-only'
]}
>
{label}
</p>
{/if}
{#if editing}
<!--
Narrow, or actions below: field, error, then Save and Cancel, so the message sits under the
field it describes. Wide with actions at the end: the actions share the field's row. The
container lives only in edit mode, so a resting value can still size to its content.
-->
<div class="@container">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={['grid gap-x-2', placement === 'end' && '@sm:grid-cols-[minmax(0,1fr)_auto]']}
onkeydown={onEditorKeydown}
>
{#if multiline}
<textarea
bind:this={field}
bind:value={draft}
id={fieldId}
rows={Math.max(2, draft.split('\n').length)}
{placeholder}
readonly={pending}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
class={[
'inline-edit-text__field ms-(--_bleed) block min-h-(--_row) min-w-0 resize-none',
'rounded-lg border border-(--_edge) bg-(--_surface) px-3 py-(--_pad-y) text-(--_ink) outline-none',
'placeholder:text-(--_placeholder) focus-visible:border-(--_ring) focus-visible:shadow-(--_focus)'
]}
{onkeydown}
{oninput}></textarea>
{:else}
<input
bind:this={field}
bind:value={draft}
id={fieldId}
type="text"
{placeholder}
readonly={pending}
autocomplete="off"
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
class={[
'inline-edit-text__field ms-(--_bleed) block min-h-(--_row) min-w-0',
'rounded-lg border border-(--_edge) bg-(--_surface) px-3 py-(--_pad-y) text-(--_ink) outline-none',
'placeholder:text-(--_placeholder) focus-visible:border-(--_ring) focus-visible:shadow-(--_focus)'
]}
{onkeydown}
{oninput}
/>
{/if}
{#if error}
<p
id={errorId}
class={[
'mt-1 flex items-start gap-1 text-[13px] leading-5 font-normal tracking-normal text-(--_danger)',
placement === 'end' && '@sm:col-start-1 @sm:row-start-2'
]}
>
<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
<path
d="M8 4.75v3.5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
<circle cx="8" cy="11" r="1" fill="currentColor" />
</svg>
</span>
<span class="min-w-0 text-pretty break-words"
><span class="sr-only">{`${errorPrefix} `}</span>{error}</span
>
</p>
{/if}
<div
class={[
'mt-2 flex min-h-(--_row) items-center justify-end gap-2',
placement === 'end' && '@sm:col-start-2 @sm:row-start-1 @sm:mt-0'
]}
>
<button
type="button"
aria-disabled={pending ? 'true' : undefined}
class={[
'inline-flex h-9 items-center rounded-lg px-3 text-sm leading-5 font-medium tracking-normal text-(--_ink) pointer-coarse:h-11 pointer-coarse:min-w-11',
'transition-[color,background-color,scale] duration-150 ease-(--_ease) motion-reduce:transition-none',
'hover:bg-(--_fill-hover) active:scale-[.98] motion-reduce:active:scale-100',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent)',
'aria-disabled:cursor-not-allowed aria-disabled:text-(--_muted) aria-disabled:hover:bg-transparent'
]}
onclick={cancel}>{cancelLabel}</button
>
<button
type="button"
aria-disabled={pending ? 'true' : undefined}
aria-label={saveLabel}
class={[
'grid h-9 items-center rounded-lg bg-(--_accent) px-3 text-sm leading-5 font-medium tracking-normal text-(--_on-accent) pointer-coarse:h-11 pointer-coarse:min-w-11',
'transition-[background-color,scale] duration-150 ease-(--_ease) motion-reduce:transition-none',
'hover:bg-(--_accent-hover) active:scale-[.98] motion-reduce:active:scale-100',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent)',
'aria-disabled:cursor-progress aria-disabled:hover:bg-(--_accent) aria-disabled:active:scale-100'
]}
onclick={save}
>
<!-- The label and the spinner share one cell, so the button holds its width; aria-label
keeps its name while the label is hidden. -->
<span class={['col-start-1 row-start-1', pending && 'invisible']}>{saveLabel}</span>
<span
class={['col-start-1 row-start-1 grid place-items-center', !pending && 'invisible']}
aria-hidden="true"
>
<svg class="inline-edit-text__spinner size-4" viewBox="0 0 16 16" fill="none">
<circle
cx="8"
cy="8"
r="6"
stroke="currentColor"
stroke-opacity=".3"
stroke-width="1.75"
/>
<path
d="M14 8a6 6 0 0 0-6-6"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
</svg>
</span>
</button>
</div>
</div>
</div>
{:else}
<div class="flex items-start gap-2">
<!-- The Edit button is the keyboard and screen reader path; a press on the text is a shortcut. -->
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
id={valueId}
class={[
'inline-edit-text__display ms-(--_bleed) flex min-h-(--_row) min-w-0 flex-1 rounded-lg border border-transparent px-3 py-(--_pad-y)',
multiline ? 'items-start' : 'items-center',
!readonly && 'cursor-text hover:bg-(--_fill-hover)'
]}
onclick={openFromDisplay}
>
{#if empty}
<span class="min-w-0 break-words text-(--_placeholder)">{emptyText ?? ''}</span>
{:else if display}
<div class="min-w-0 flex-1 break-words">{@render display(value)}</div>
{:else}
<span
class={[
'min-w-0 text-pretty break-words text-(--_ink)',
multiline && 'whitespace-pre-line'
]}>{value}</span
>
{/if}
</div>
{#if !readonly}
<div class="flex h-(--_row) shrink-0 items-center">
<button
bind:this={editTrigger}
type="button"
aria-label={editName}
aria-describedby={valueId}
class={[
'grid h-9 place-items-center rounded-lg text-sm leading-5 font-medium tracking-normal pointer-coarse:h-11',
editButton === 'icon' ? 'w-9 pointer-coarse:w-11' : 'px-3',
saved ? 'text-(--_accent)' : 'text-(--_muted)',
'transition-[color,background-color,scale] duration-150 ease-(--_ease) motion-reduce:transition-none',
'hover:bg-(--_fill-hover) hover:text-(--_ink) active:scale-[.98] motion-reduce:active:scale-100',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent)'
]}
onclick={startEdit}
>
<!-- The pencil and the check share one cell, so the button keeps its width. The text
stays the action, so the visible label is always part of the button's name. -->
<span
class={[
'col-start-1 row-start-1 flex items-center gap-2 transition-opacity duration-150 motion-reduce:transition-none',
saved && 'opacity-0'
]}
aria-hidden="true"
>
<svg class="size-4 shrink-0" viewBox="0 0 16 16" fill="none">
<path
d="M10.5 2.75 13.25 5.5 5.75 13H3v-2.75l7.5-7.5Z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
<path d="m9 4.25 2.75 2.75" stroke="currentColor" stroke-width="1.75" />
</svg>
{#if editButton === 'text'}<span class="text-(--_ink)">{editText}</span>{/if}
</span>
<span
class={[
'col-start-1 row-start-1 flex items-center justify-center gap-2 transition-opacity duration-150 motion-reduce:transition-none',
!saved && 'opacity-0'
]}
aria-hidden="true"
>
<svg class="size-4 shrink-0" viewBox="0 0 16 16" fill="none">
<path
d="m3.25 8.5 3 3 6.5-7"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{#if editButton === 'text'}<span class="text-(--_ink)">{editText}</span>{/if}
</span>
</button>
</div>
{/if}
</div>
{/if}
<!-- One persistent polite region, so saving, saved and failed are each announced once. -->
<p class="sr-only" role="status">{status}</p>
</div>
<style>
/* Public tokens: set --inline-edit-text-* on the component or any ancestor to retone it. */
.inline-edit-text {
--_accent: var(--inline-edit-text-accent, #18181b);
--_on-accent: var(--inline-edit-text-on-accent, #ffffff);
--_ink: var(--inline-edit-text-ink, #18181b);
--_muted: var(--inline-edit-text-muted, #52525b);
--_placeholder: var(--inline-edit-text-placeholder, #71717a);
/* 3:1 against white: the field's edge is the only thing marking it (WCAG 1.4.11). */
--_hairline: var(--inline-edit-text-hairline, rgb(0 0 0 / 0.44));
--_surface: var(--inline-edit-text-surface, #ffffff);
--_danger: var(--inline-edit-text-danger, #b91c1c);
/* Formulas: hover tones are mixed from the tokens, so they follow any retone. */
--_fill-hover: color-mix(in oklab, var(--_surface), var(--_ink) 5%);
--_accent-hover: color-mix(in oklab, var(--_accent), var(--_surface) 16%);
--_ease: cubic-bezier(0.2, 0, 0, 1);
--_edge: var(--_hairline);
--_ring: var(--_accent);
--_focus: 0 0 0 1px var(--_ring), 0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
/*
* The display box and the field share one height (one line of the inherited text plus the
* padding and edge) and one inset. The bleed pulls the box out by its padding and edge, so
* the value sits on the label's left edge in both modes and the field grows around it.
*/
--_row: calc(1lh + 0.875rem);
--_bleed: calc(-0.75rem - 1px);
/* 6 px: a 22 px line, this padding and the edge make the 36 px app control height. */
--_pad-y: 0.375rem;
/* Touch screens: 44 px boxes; the text stays centred, so it still does not move. */
@media (pointer: coarse) {
--_row: max(calc(1lh + 0.875rem), 2.75rem);
}
}
.inline-edit-text[data-invalid] {
--_edge: var(--_danger);
--_ring: var(--_danger);
}
/*
* Type defaults sit in the components layer under :where(), so a text size passed through
* class (a heading-sized title) wins, and the display and the field both inherit it.
*/
@layer components {
:where(.inline-edit-text) {
font-size: 0.875rem;
line-height: 1.375rem;
}
}
/*
* The field's edge fades in from the display box's transparent one, so editing reads as the
* box waking up rather than a form replacing the text. Colour only; nothing moves.
*/
.inline-edit-text__field {
transition-property: border-color, box-shadow, background-color;
transition-duration: 150ms;
transition-timing-function: var(--_ease);
}
@starting-style {
.inline-edit-text__field {
border-color: transparent;
background-color: transparent;
}
}
.inline-edit-text__display {
transition: background-color 150ms var(--_ease);
}
/* Sizes to its content where field-sizing exists; rows is the fallback elsewhere. */
textarea.inline-edit-text__field {
field-sizing: content;
}
.inline-edit-text__spinner {
animation: inline-edit-text-spin 0.8s linear infinite;
}
@keyframes inline-edit-text-spin {
to {
rotate: 360deg;
}
}
@media (prefers-reduced-motion: reduce) {
.inline-edit-text__field,
.inline-edit-text__display {
transition: none;
}
/* The arc holds still; the status region still says Saving. */
.inline-edit-text__spinner {
animation: none;
}
}
/* Forced colours drop box-shadows, so focus becomes a system-colour outline. */
@media (forced-colors: active) {
.inline-edit-text__field:focus-visible {
outline: 2px solid Highlight;
outline-offset: 2px;
}
}
</style>
Usage#
On this pagePass the saved value, a label and an async onSave that persists the change and returns { ok: true } or { ok: false, message }. The component renders the value with an Edit button, runs the editor, ignores duplicate saves, keeps the draft when a save fails and writes the saved value back through bind:value. It does not persist anything, resolve concurrent edits or check permissions; your onSave and your server do.
- Suggested location
src/lib/components/inline-edit-text-01
Limitations
- The display box and the field extend 13 px past the content's start edge (its padding and border), so the value lines up with the label and the text around it. Leave that much room, or 17 px for the focus halo, before any ancestor that clips with overflow: hidden.
- A single-line value that wraps in the display shows on one scrolling line in the input, as any text input does. Use multiline for values that are meant to wrap.
- An error appears under the field when validate or
onSavefails, and the component grows by that line until the draft changes. Reserving the line would make opening the editor move the content below instead. - The textarea sizes to its text where the browser supports field-sizing (Chromium); elsewhere it starts at one row per line and scrolls past that.
- An unchanged draft closes the editor without calling
onSave. The component saves exactly what was typed; to trim or normalise, do it inonSaveand return the result as value. validate can only block a save, not change it. - readonly only hides the Edit button. Enforce who may change a value on your server.
- Opening the editor through bind:editing does not move focus, so it never steals focus on page load. Move focus yourself if that is what you want.
Example
<script lang="ts">
import InlineEditText from '$lib/components/inline-edit-text-01/InlineEditText.svelte';
let name = $state('Website redesign');
async function rename(next: string) {
const response = await fetch('/api/projects/website-redesign', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: next })
});
if (response.status === 409) return { ok: false as const, message: 'Name already in use. Choose another.' };
if (!response.ok) return { ok: false as const, message: "Couldn't save. Try again." };
const project: { name: string } = await response.json();
return { ok: true as const, value: project.name };
}
</script>
<div class="max-w-md">
<InlineEditText
label="Project name"
bind:value={name}
validate={(next) => (next.trim() ? null : 'Enter a project name.')}
onSave={rename}
/>
</div>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Inline editable text#
A saved value shown as text with an Edit button. Edit opens an input (or a textarea with
multiline) in the same box, so the value stays where it was and the field's edge grows
around it. Save and Cancel are explicit: blur never saves.
The save contract#
onSave(next) returns a promise of:
{ ok: true }: the draft is saved and shown.{ ok: true, value }:valueis shown instead, for a server that trims or normalises.{ ok: false, message }: the editor stays open with the draft,messagesits under the field and focus returns to the field. A thrown error behaves the same witherrorMessage.
validate(next) runs first and blocks the save when it returns a message. A draft equal to
the saved value closes the editor without calling onSave. While a save runs, further saves,
Cancel and Escape are ignored.
Keys and focus#
| Key | Single line | Multiline |
|---|---|---|
| Enter | Save | New line |
| Ctrl or Cmd + Enter | Save | Save |
| Escape | Cancel | Cancel |
Focus moves into the field on Edit and back to the Edit button after Save or Cancel.
A title#
<InlineEditText
class="text-2xl leading-8 font-semibold tracking-tight rtl:tracking-normal"
label="Board title"
hideLabel
bind:value={title}
onSave={renameBoard}
>
{#snippet display(text)}
<h1 class="[letter-spacing:inherit] [font:inherit]">{text}</h1>
{/snippet}
</InlineEditText>In a page header, let the title and its pencil hug the text while it rests, and give the
editor the row once it opens; the root carries data-editing while the editor is open:
.board-title:not(:has([data-editing])) {
width: fit-content;
max-width: 100%;
}Room around it#
The box hangs 13 px past the start edge so the value lines up with the label and the text
around it. Leave that room (17 px with the focus halo) before any ancestor with
overflow: hidden.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
value | string | Yes | None | Current saved value. Bindable: a successful save writes the saved (or returned) value back. |
label | string | Yes | None | What is being edited, such as 'Project name'. Labels the field and names the Edit button. |
onSave | (next: string) => Promise<{ ok: true; value?: string } | { ok: false; message: string }> | Yes | None | Persists the change. A returned value replaces the display (a normalised name, say). A failure, or a thrown error, keeps the editor open with the draft. |
multiline | boolean | No | false | Edit in a textarea: Enter adds a line, Ctrl or Cmd+Enter saves, and line breaks show in the display. |
validate | (next: string) => string | null | No | None | Local check before onSave. Return a message to block the save and show it under the field, or null. |
emptyText | string | No | None | Shown in the placeholder tone when the value is empty, such as 'Add a description'. |
display | Snippet<[string]> | No | None | Custom rendering of the saved value, such as a heading. Receives the value; not used while the value is empty. |
placeholder | string | No | None | Placeholder inside an empty field. Never a substitute for the label. |
saveLabel | string | No | 'Save' | Save button text. |
cancelLabel | string | No | 'Cancel' | Cancel button text. |
editLabel | string | No | None | Accessible name of the Edit button. Defaults to editText plus the label with its first letter lower-cased ('Edit project name'), so the visible text is always part of the name; set it when translating. |
editText | string | No | 'Edit' | Visible text of the Edit button when editButton is 'text'. |
editButton | 'icon' | 'text' | No | 'icon' | A pencil icon button, or a text button with the pencil and editText. Both carry editLabel as their name. |
actions | 'end' | 'below' | No | None | Save and Cancel beside the field (wrapping below it when the row is narrow) or below it. Defaults to 'below' for multiline and 'end' otherwise. |
hideLabel | boolean | No | false | Keep the label for assistive technology only, when the page around the value already says what it is. |
readonly | boolean | No | false | Show the value with no Edit button. Hiding the button is not authorisation. |
editing | boolean | No | false | Bindable: whether the editor is open. Opening it this way starts from the saved value and does not move focus. |
savingLabel | string | No | 'Saving' | Announced, after the label, while onSave runs. |
savedLabel | string | No | 'Saved' | Announced, after the label, when a save succeeds. The Edit button shows a check for two seconds meanwhile. |
errorMessage | string | No | "Couldn't save. Try again." | Shown when onSave throws or fails with an empty message. |
errorPrefix | string | No | 'Error:' | Read before the error by screen readers only. |
class | string | No | None | Extra classes on the root. A text size here (text-2xl font-semibold) is shared by the display and the field. |
Customization#
On this pageChange the copy through props, the text size through class, and retone the component through eight --inline-edit-text-* CSS variables. Hover fills and the pressed Save tone are mixed from those, so they follow any retone.
- Accent:
--inline-edit-text-accentfills Save, draws the field's focus edge and every focus ring, and tints the saved check.--inline-edit-text-on-accentis Save's text; keep the pair at 4.5:1. - Neutrals:
--inline-edit-text-inkis the value, label and Cancel;--inline-edit-text-mutedthe resting pencil;--inline-edit-text-placeholdertheemptyTextand placeholder;--inline-edit-text-hairlinethe field's edge (keep it at 3:1 against the page, since it is the only thing marking the field);--inline-edit-text-surfacethe field's fill. - Danger:
--inline-edit-text-dangercolours the error edge, ring, icon and message. The default isred-700(6.5:1 on white). - Worked retone for a
#09090bband:--inline-edit-text-accent:#fafafa;--inline-edit-text-on-accent:#09090b;--inline-edit-text-ink:#fafafa;--inline-edit-text-muted:#a1a1aa;--inline-edit-text-placeholder:#a1a1aa;--inline-edit-text-hairline: rgb(255 255 255 / 0.35);--inline-edit-text-surface:#18181b;--inline-edit-text-danger:#f87171. Add color-scheme: dark on the band. - Cream page such as a bakery's
#fbf7f0: set--inline-edit-text-surface:#fffdf9and leave the hairline alone; it is alpha black, so it tints with the page. - Text size: the display and the field inherit the root's type. Pass class="
text-2xlleading-8font-semiboldtracking-tight" for a title and render the value as a heading through display, with font: inherit on the heading. The label and the buttons keep their own size. - Layout: actions 'end' for short values in rows, 'below' for descriptions. Put each instance in its own settings row with 20 to 24 px between rows; the value lines up with the row's other text.
- Copy: translate
saveLabel,cancelLabel,editLabel,savingLabel,savedLabel,errorMessageanderrorPrefixwith the page; the defaulteditLabelis English.
Public CSS variables
| Variable | Token |
|---|---|
--inline-edit-text-accent | accent |
--inline-edit-text-on-accent | onAccent |
--inline-edit-text-ink | ink |
--inline-edit-text-muted | muted |
--inline-edit-text-placeholder | placeholder |
--inline-edit-text-hairline | hairline |
--inline-edit-text-surface | surface |
--inline-edit-text-danger | danger |
Accessibility#
On this page- The Edit button is a native button named 'Edit' plus the label ('Edit project name'), described by the value, and always visible, never only on hover. A press on the text also opens the editor, but only as a shortcut beside the button.
- The field is labelled by a native label holding label;
hideLabelkeeps it for assistive technology only. Errors setaria-invalidand are linked witharia-describedby, with an icon and a visually hiddenerrorPrefix, so colour is never the only signal. - Opening the editor moves focus into the field, selecting a single-line value and placing the caret after a multiline one. Save and Cancel return focus to the Edit button; a failed save returns it to the field.
- Enter saves a single line, Ctrl or Cmd+Enter saves multiline, Escape cancels; keys pressed while an IME is composing are left alone. Blur does nothing, so tabbing to Save or away never saves by surprise.
- Saving, saved and failed are announced once each through a persistent polite status region. While saving, Save and Cancel are
aria-disabledrather than disabled, so focus stays put, and the field is read-only. - Buttons and the field are 44 px tall on touch screens. Keep accent at 3:1 against the page for focus rings and at 4.5:1 with on-accent for Save's text.
- readonly removes the Edit button only. Authorise every change on your server; hiding a control is not access control.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.