Edit in Place

PreviousNext

Text that becomes an input where it sits — the box climbs a rung on entering edit and drops back on commit. Elevation is the edit state, and the box change is a small-scale morph.

Project

Owner
Budget (USD)

Installation

pnpm dlx shadcn@latest add https://matos-ui.com/r/edit-in-place.json

Usage

import { EditInPlace } from '@/components/matos-ui/edit-in-place'
const [name, setName] = useState('Quarterly planning')

<EditInPlace label="Project name" value={name} onValueChange={setName} />

A click — or Enter, Space, F2 — enters edit. Enter commits, Esc reverts, and leaving the field commits (submitOnBlur, on by default).

Elevation is the edit state

At rest the box is flush with its substrate — no fill, no ring — and reads as plain text. On edit it climbs one rung up the elevation ladder, grows its padding, rounds its corners and draws a border. Committing undoes all of it.

The whole transition is spring.morph, the shape tier — the same one a bento uses across hundreds of pixels, here across a handful. It is the argument that the tier holds at both scales.

How it's built

  • The swap moves nothing. An invisible sizer holds the exact text width; the button and the input overlay it, so nothing reflows as they trade places and the caret lands where the text was. The sizer renders the same thing the visible layer does (renderValue included), so the width always matches.
  • layout + layoutDependency={editing} animates the box only on the mode switch — typing changes the sizer width without triggering the morph.
  • The ✓ key winds back before it appearsease.anticipate, the 12-principles anticipation beat, with a beat of delay so it enters after the box has settled.
  • prefers-reduced-motion keeps the crossfade and the colour shift, and drops the box scale, the travel and the anticipation dip.

Validation

validate is given the value and returns true to accept or a string to reject — the string shows below the field, the box does an attentionShake, and editing stays open.

<EditInPlace
  label="URL slug"
  value={slug}
  onValueChange={setSlug}
  validate={(v) =>
    /^[a-z0-9-]+$/.test(v) || 'Lowercase letters, numbers and dashes only.'
  }
/>

Accessibility

  • At rest it is a <button aria-label="Edit …">; on edit, an <input> with the same accessible name, focused and with its text selected.
  • Esc reverts and returns focus to the trigger. A validation error sets aria-invalid and wires aria-describedby to the message (role="alert").

Reference

EditInPlace Props

PropTypeDefaultDescription
valuestringThe editable value.
onValueChange(value: string) => voidCommit — Enter, the ✓ key, or blur.
onCancel() => voidFires on Escape or the ✕ key, after the value is reverted.
labelstringAccessible name for the field. Required.
placeholderstring"Empty"Shown when the value is empty.
validate(value: string) => boolean | stringA string rejects the commit and becomes the error message.
submitOnBlurbooleantrueLeaving the field commits; false reverts.
selectOnEditbooleantrueSelect the text when editing starts.
trimbooleantrueTrim surrounding whitespace on commit.
maxLengthnumber
renderValue(value: string) => ReactNodeFormat the resting display (the editable string is value).
disabledbooleanfalse
size"sm" | "md" | "lg""md"
editingbooleanControlled edit state.
defaultEditingbooleanfalse
onEditingChange(editing: boolean) => void
namestringName for a hidden input, so the value posts with a form.