Inline Edit
Text that turns into an input on click; Enter saves with a brief tick, Escape restores the old value.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/inline-edit.json'use client'
import { useEffect, useRef, useState } from 'react'
import './inline-edit.css'
export interface InlineEditProps {
value?: string
defaultValue?: string
onSave?: (v: string) => void
label?: string
size?: 'md' | 'lg'
fill?: 'dark' | 'light'
className?: string
}
/** Click-to-edit text. */
export function InlineEdit({ value, defaultValue = '', onSave, label = 'Name', size = 'lg', fill = 'dark', className }: InlineEditProps) {
const [inner, setInner] = useState(defaultValue)
const v = value ?? inner
const [edit, setEdit] = useState(false)
const [draft, setDraft] = useState(v)
const [saved, setSaved] = useState(0)
const [bad, setBad] = useState(0)
const [msg, setMsg] = useState('')
const inRef = useRef<HTMLInputElement>(null)
const btnRef = useRef<HTMLButtonElement>(null)
useEffect(() => { if (edit) inRef.current?.select() }, [edit])
const close = (save: boolean) => {
if (save) {
const d = draft.trim()
if (!d) { setBad((b) => b + 1); return }
if (d !== v) { setInner(d); onSave?.(d); setSaved((s) => s + 1); setMsg(`${label} saved`) }
} else setMsg('Edit cancelled')
setEdit(false)
requestAnimationFrame(() => btnRef.current?.focus())
}
return (
<div className={['sd-ie', className].filter(Boolean).join(' ')} data-fill={fill} data-size={size}>
<p className="sd-ie__cap">{label}</p>
{edit ? (
<input
ref={inRef}
key={bad}
className="sd-ie__in"
data-bad={bad ? '' : undefined}
aria-label={label}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => close(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') close(true)
if (e.key === 'Escape') close(false)
}}
/>
) : (
<button ref={btnRef} type="button" className="sd-ie__view" aria-label={`Edit ${label}: ${v}`} onClick={() => { setDraft(v); setEdit(true) }}>
<span className="sd-ie__txt">{v}</span>
{saved > 0 && <svg key={saved} viewBox="0 0 16 16" className="sd-ie__ok" aria-hidden><path d="m3.5 8.5 3 3 6-7" /></svg>}
<svg viewBox="0 0 16 16" className="sd-ie__pen" aria-hidden><path d="M10.5 2.5 13.5 5.5 6 13H3v-3z" /></svg>
</button>
)}
<p className="sd-ie__hint" aria-hidden>{edit ? 'Enter to save · Esc to cancel' : 'Click to rename'}</p>
<span className="sr" aria-live="polite">{msg}</span>
</div>
)
}.sd-ie { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-ie[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-ie button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-ie :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-ie .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-ie { width: min(100%, 320px); }
.sd-ie__cap { font-size: 11.5px; color: var(--mute); margin: 0 0 6px 10px; text-transform: uppercase; letter-spacing: 0.06em; }
.sd-ie__view, .sd-ie__in { display: flex; align-items: center; gap: 8px; width: 100%; height: 44px; padding: 0 10px; border-radius: 10px; font-size: 15px; font-weight: 550; text-align: left; }
.sd-ie[data-size='lg'] .sd-ie__view, .sd-ie[data-size='lg'] .sd-ie__in { height: 52px; font-size: 22px; letter-spacing: -0.01em; }
.sd-ie__view { transition: background-color 160ms; }
.sd-ie__view:hover { background: var(--soft) !important; }
.sd-ie__txt { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sd-ie__pen { width: 15px; height: 15px; flex: none; color: var(--mute); opacity: 0; transition: opacity 160ms; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linejoin: round; }
.sd-ie__view:hover .sd-ie__pen, .sd-ie__view:focus-visible .sd-ie__pen { opacity: 1; }
.sd-ie__ok { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; animation: sd-ie-ok 1400ms ease forwards; }
@keyframes sd-ie-ok { 0% { opacity: 0; transform: scale(0.4); } 15% { opacity: 1; transform: none; } 80% { opacity: 1; } 100% { opacity: 0; } }
.sd-ie__in { border: 0; outline: 0; color: var(--fg); background: var(--bg); box-shadow: inset 0 0 0 1.5px var(--fg); font-family: inherit; }
.sd-ie__in[data-bad] { animation: sd-ie-shake 300ms; }
@keyframes sd-ie-shake { 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
.sd-ie__hint { margin: 6px 0 0 10px; font-size: 11.5px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-ie *, .sd-ie *::before, .sd-ie *::after { animation: none !important; transition: none !important; } }Overview
Hovering shows a pencil and a faint well so it reads as editable. Clicking swaps in an input at the same size, selected, so typing replaces it.
Enter or blur saves and flashes a tick; Escape cancels. Empty values are refused with a small shake.
When to use
- Renaming files, projects, boards, playlist titles.
When not to use
- Fields that need validation messages or several inputs: use a form.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/inline-edit.json$
npx shadcn@latest add https://sundaeui.dev/r/inline-edit.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/inline-edit.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/inline-edit.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy inline-edit.tsx and inline-edit.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { InlineEdit } from '@/components/sundae/inline-edit'
<InlineEdit />Props
| Prop | Type | Default |
|---|---|---|
value / defaultValueText. | string | — |
onSaveCalled with the new value. | (v: string) => void | — |
labelAccessible name of the field. | string | 'Name' |
sizeType size. | 'md' | 'lg' | 'lg' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- At rest it is a button named "Edit Name: …"; editing moves focus to a labelled input.
- Save and cancel return focus to the button and are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-ie; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Enter
- Save
- Esc
- Cancel
FAQ
- Is Inline Edit free?
- Yes. Inline Edit is free for personal and commercial projects, with full source on this page.
- How do I install Inline Edit?
- Run npx shadcn@latest add https://sundaeui.dev/r/inline-edit.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Inline Edit support keyboard and screen readers?
- At rest it is a button named "Edit Name: …"; editing moves focus to a labelled input. Save and cancel return focus to the button and are announced.
- Does Inline Edit respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →OTP Field
Forms · Refined- Pro
Wheel Picker
Forms · Refined Password Strength
Forms · RefinedStar Rating
Forms · RefinedSearch Field
Forms · Refined- Pro
Number Scrubber
Forms · Refined