Controls
Inline edit
Text that turns into a field in place, saved with Enter and cancelled with Escape.
Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.
Preview
Usage
inline-edit-demo.tsx
import { CalendarDays, Clock, MapPin, NotebookText, Tag } from 'lucide-react'
import { useState } from 'react'
import { InlineEdit } from '@/components/inline-edit'
export default function InlineEditDemo() {
const [event, setEvent] = useState({
name: 'Spring sample sale',
date: 'Saturday 14 March',
time: '10:00 – 16:00',
place: 'Back room, Main Street store',
notes: 'Members get in at 9.\nCards and cash.',
})
const set = (key: keyof typeof event) => (v: string) => setEvent((e) => ({ ...e, [key]: v }))
return (
<div className="mx-auto w-full max-w-md rounded-[28px] border bg-muted/50 p-1.5 shadow-sm">
<div className="overflow-hidden rounded-[22px] border bg-card">
<p className="border-b bg-muted/40 px-5 py-3 text-xs font-medium tracking-wide text-muted-foreground uppercase">Event details</p>
<div className="space-y-2 p-3 sm:space-y-1">
<InlineEdit label="Event" icon={<Tag />} value={event.name} onSave={set('name')} />
<InlineEdit label="Date" icon={<CalendarDays />} value={event.date} onSave={set('date')} />
<InlineEdit label="Time" icon={<Clock />} value={event.time} onSave={set('time')} />
<InlineEdit label="Place" icon={<MapPin />} value={event.place} onSave={set('place')} />
<InlineEdit label="Notes" icon={<NotebookText />} value={event.notes} onSave={set('notes')} multiline />
</div>
</div>
</div>
)
}Source
inline-edit.tsx
// Based on Watermelon UI's “Inline edit” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten as one reusable field: Enter saves, Esc cancels, and cancel restores the value.
import { Check, Pencil, X } from 'lucide-react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type InlineEditProps = {
label: string
value: string
onSave: (value: string) => void
icon?: ReactNode
multiline?: boolean
/** Shown when the value is empty. */
placeholder?: string
editLabel?: string
saveLabel?: string
cancelLabel?: string
className?: string
}
/** A value that reads like text until you edit it. The pencil swaps for save and cancel. */
export function InlineEdit({
label,
value,
onSave,
icon,
multiline = false,
placeholder = 'Empty',
editLabel = 'Edit',
saveLabel = 'Save',
cancelLabel = 'Cancel',
className,
}: InlineEditProps) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(value)
const field = useRef<HTMLInputElement & HTMLTextAreaElement>(null)
const pencil = useRef<HTMLButtonElement>(null)
const id = useId()
useEffect(() => {
if (editing) field.current?.select()
}, [editing])
const start = () => {
setDraft(value)
setEditing(true)
}
const stop = (save: boolean) => {
if (save && draft.trim() !== value) onSave(draft.trim())
setEditing(false)
requestAnimationFrame(() => pencil.current?.focus())
}
const swap = { initial: { opacity: 0, y: 24 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: 24 } }
const Field = multiline ? 'textarea' : 'input'
return (
<MotionConfig reducedMotion="user" transition={{ duration: 0.35, ease: [0.19, 1, 0.22, 1] }}>
<div className={cn('flex flex-col gap-1 sm:flex-row sm:gap-4', multiline ? 'sm:items-start' : 'sm:items-center', className)}>
<label htmlFor={editing ? id : undefined} className="flex shrink-0 items-center gap-2.5 text-sm text-muted-foreground sm:w-32 sm:py-2.5 [&_svg]:size-4.5">
{icon}
{label}
</label>
<div
className={cn(
'group flex min-h-11 w-full min-w-0 gap-2 overflow-hidden rounded-xl px-3 transition-colors duration-150',
editing ? 'bg-muted' : 'hover:bg-muted/60',
multiline ? 'items-start py-2' : 'items-center',
)}
>
{editing ? (
<Field
ref={field}
id={id}
value={draft}
rows={multiline ? 3 : undefined}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') stop(false)
if (e.key === 'Enter' && (!multiline || e.metaKey || e.ctrlKey)) {
e.preventDefault()
stop(true)
}
}}
className="w-full min-w-0 resize-none bg-transparent font-medium outline-none"
/>
) : (
<p className={cn('w-full min-w-0 font-medium', multiline ? 'whitespace-pre-line' : 'truncate', !value && 'text-muted-foreground')}>
{value || placeholder}
</p>
)}
<div className="flex shrink-0 items-center">
<AnimatePresence mode="popLayout" initial={false}>
{editing ? (
<motion.div key="edit" {...swap} className="flex gap-1">
<button
type="button"
aria-label={saveLabel}
onClick={() => stop(true)}
className="grid size-8 place-items-center rounded-lg bg-foreground text-background active:scale-95"
>
<Check className="size-4" aria-hidden />
</button>
<button
type="button"
aria-label={cancelLabel}
onClick={() => stop(false)}
className="grid size-8 place-items-center rounded-lg bg-card text-foreground shadow-sm active:scale-95"
>
<X className="size-4" aria-hidden />
</button>
</motion.div>
) : (
<motion.button
key="view"
ref={pencil}
{...swap}
type="button"
aria-label={`${editLabel} ${label.toLowerCase()}`}
onClick={start}
className="grid size-8 place-items-center rounded-lg border bg-card text-muted-foreground opacity-100 shadow-sm transition-opacity duration-150 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
>
<Pencil className="size-3.5" aria-hidden />
</motion.button>
)}
</AnimatePresence>
</div>
</div>
</div>
</MotionConfig>
)
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/inline-edit.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/inline-edit