Inputs
Inline Edit
Click the text, it becomes a field. Nothing on the page moves.
import { InlineEdit } from 'omaris' Examples
Basic
Click the title. The line under it does not move: the reading view, the input and an invisible copy of the text all share one grid cell.
This line stays exactly where it is.
<script lang="ts"> import { InlineEdit } from 'omaris'; let title = $state('Untitled board');</script><div class="w-96 max-w-full rounded-shape-md border border-border p-4"> <InlineEdit bind:value={title} label="Board name" class="text-xl font-semibold" /> <p class="pt-2 text-sm text-muted-foreground">This line stays exactly where it is.</p></div> Multiline
A textarea that grows by the line. Enter is a newline here, ⌘/Ctrl+Enter saves, Escape cancels.
<script lang="ts"> import { InlineEdit } from 'omaris'; let note = $state('Two lines of notes,\nand the box grows with them.'); let empty = $state('');</script><div class="flex w-96 max-w-full flex-col gap-4"> <InlineEdit bind:value={note} multiline block label="Notes" /> <InlineEdit bind:value={empty} placeholder="Add a description" block label="Description" /></div> Async
A save that can fail. The value is only written once the promise settles, so a rejection leaves the field open on what was typed — with the reason floating under it, where it cannot push the page around.
<script lang="ts"> import { InlineEdit } from 'omaris'; let name = $state('Anything but "no"'); async function save(next: string) { await new Promise((resolve) => setTimeout(resolve, 800)); if (next.toLowerCase() === 'no') throw new Error('That name is taken'); }</script><div class="w-80 max-w-full"> <InlineEdit bind:value={name} label="Name" onsave={save} validate={(next) => (next.length < 2 ? 'At least two characters' : null)} /></div> Overridden
A right-aligned figure in a table row: monospace, tabular, no pencil.
<script lang="ts"> import { InlineEdit } from 'omaris'; let revenue = $state('12 500'); let cost = $state('4 200');</script><div class="w-64 max-w-full overflow-hidden rounded-shape-md border border-border"> {#each [{ label: 'Revenue', get: () => revenue, set: (v: string) => void (revenue = v) }, { label: 'Cost', get: () => cost, set: (v: string) => void (cost = v) }] as row (row.label)} <div class="flex items-center justify-between border-b border-border px-3 py-2 last:border-b-0"> <span class="text-sm text-muted-foreground">{row.label}</span> <InlineEdit value={row.get()} onsave={row.set} align="end" size="sm" pencil={false} label={row.label} class="font-mono tabular-nums" classes={{ view: 'hover:bg-primary/10' }} /> </div> {/each}</div> When to use it
Use it for
- Renaming the thing on screen: a board, a document, a column, a file. Opening a dialog to change six characters is too much.
- A field in a detail panel or card that is read far more often than it is changed: a title, an owner, a note, a due-date label.
- A cell in a light table of editable values. Use
align="end"andpencil={false}. - A description.
multilinegrows the box by the line as it is typed.
Not for
- A form → Text Field and a submit button. Click-to-edit fields hide that there is anything to fill in.
- Something required before anything else works. An empty inline edit reads as "nothing here", not "required".
- A value with a picker behind it, like a date, a currency or a country → Picker, Select or Price Input in a small form.
- Text longer than a couple of lines → Textarea or Rich Text Editor.
Do
- Give it a
label. It is the accessible name in both states, read as "Board name: Untitled board", and there is no visible label to fall back on. - Return a promise from
onsave. The field shows a spinner, writesvalueonce it resolves, and stays open with the message if it rejects. - Use
validatefor the rules. The message floats under the box and never pushes the page around. - Set
placeholderto what should be there: "Add a description". An empty box does not look editable. Turnactionson for touch, where there is no obvious way to leave the field; leave them off on desktop. - Put the type on the component (
class="text-xl font-semibold"), not on the parts. Everything inside inherits it, so both states stay the same size.
Don't
- Swap a
<span>for an<input>yourself. The border, padding and line height all change, and the rows below jump twice per edit. - Put one inside a row that also handles clicks. The edit and the row's action fight over the same press.
- Save on every keystroke.
onsavefires once, on Enter or on leaving. - Use
trigger="manual"without giving people something to press.
Quick reference
size smmd(default)lg
align start(default)centerend
API
InlineEdit
Click the text, it becomes a field. Nothing on the page moves.
That last part is the whole component. The usual way to build this — swap a <span> for an <input> — changes the box's height, its padding and its font all at once, so the line jumps, the row below it jumps, and on a card the whole layout reflows twice per edit. Here the reading view, the input and an invisible copy of the text share one grid cell: the copy sizes the cell, the other two stretch to fill it, and all three carry identical type and padding. The box is the same size before, during and after — and it grows with what is typed, in one place, as you type.
import { InlineEdit } from 'omaris' <InlineEdit bind:value={name} onsave={rename} /><InlineEdit bind:value={note} multiline variant="body-sm" /> Parts
Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.
root- The stack. Its size comes from the invisible copy inside it.
sizer- The invisible copy of the text. Nothing else sizes this component.
view- What you see when it is not being edited.
control- The control. Same font, same padding, same border box as the view.
pencil- The pencil. Sits in space that is reserved whether it shows or not.
placeholder- The text when there is none — "Add a description".
error- The error line. Floats under the box, so it never pushes anything.
actions- The confirm/cancel pair, when
actionsis on. Also floating. action- One of those buttons.
busy- The spinner shown while an async
onsaveis in flight.
Props
value bindableDefaults to ''
string The text. Bindable, and only written once a save succeeds.
editing bindableDefaults to false
boolean Whether the field is open. Bindable — the way to open it from a button.
size Defaults to 'md'
InlineEditSize smmdlg
align Defaults to 'start'
InlineEditAlign startcenterend
multiline Defaults to false
boolean A textarea that grows by the line instead of an input.
block Defaults to false
boolean Fill the container rather than hugging the text.
trigger Defaults to 'click'
InlineEditTrigger What opens the field. manual leaves it to bind:editing.
submitOn Defaults to 'both'
InlineEditSubmit When the change is kept. Escape always cancels.
pencil Defaults to true
boolean Show the pencil on hover. Its space is reserved either way.
actions Defaults to false
boolean Float a confirm/cancel pair under the field — worth it on touch.
selectOnEdit Defaults to true
boolean Select the whole value when the field opens.
placeholder Defaults to 'Empty'
string Shown, greyed, when the value is empty.
label string Accessible name — what this text is. Required in practice.
disabled Defaults to false
boolean readonly Defaults to false
boolean Reads, never edits. The hover hint and the pencil go away.
maxlength number validate (value: string) => string | null Checked before saving. Return a message to refuse the change and keep the field open; return null to let it through.
onsave (value: string) => void | Promise<unknown> Called with the new text. Return a promise and the field waits on it, showing a spinner — a rejection keeps the field open with the message.
oncancel () => void Called when the edit is abandoned.
view Snippet<[string]> Replaces the reading view — a badge, an avatar beside the name.
class string classes InlineEditClasses Per-part Tailwind overrides. class still covers the root.
ref bindableDefaults to null
HTMLInputElement | HTMLTextAreaElement | null