Inline edit
Click a value to change it in place. Text, headings, longer text, choices, numbers and money. Enter saves, Escape keeps the old value, checks speak up under the field, saving shows its state.
Change a value right where it stands
Inline edit lets people click a value and change it in place, without a form and without a save page. Enter saves, Escape restores, checks explain what is wrong, and the saving and saved states are announced by aria-live before the focus returns to the text.
Profile edits every value in place, the name saves after a short request, the role saves on choice, the address is checked and the server can refuse it, and the about text counts its characters. Heading keeps a document title a real heading while editing, the field takes the size of the text, so nothing jumps. Table cells edit a price list in place with numbers right aligned, money in the format of the language and status as a choice, every change counts into the total below. Settings rows give each value a label and its own edit button, for values people change rarely and on purpose.
Use inline edit for values people change often and quickly, names, titles, prices. Show a clear edit hint on hover and focus, otherwise nobody knows the text is editable. Save on Enter and on leaving the field, and keep undo close when the save cannot be reversed.
Variants
Details
What Inline edit brings along, read from the registry and the source.
- Category
- Forms and inputs
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/inline-edit.tsx
- npm packages
- None
- Languages
- English and German, 16 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The InlineEditProps interface from components/ui/inline-edit.tsx, with the comments from the code.
typeoptionalInlineEditTypetextheadingtextareaselectnumbercurrency
valueoptionalInlineEditValuedefaultValueoptionalInlineEditValueonSaveoptional(value: InlineEditValue) => SaveResult | Promise<SaveResult>Saves the value. May be async. A string answer is shown as the error and the field stays open.
labelrequiredstringAccessible name, and the visible label with showLabel
showLabeloptionalbooleanplaceholderoptionalstringShown in place of an empty value, e.g. "Add a short bio"
optionsoptional{ value: string; label: string }[]requiredoptionalbooleanminLengthoptionalnumbermaxLengthoptionalnumberminoptionalnumbermaxoptionalnumberstepoptionalnumberpatternoptionalstringA regular expression the text must match, e.g. ^[A-Z]{2}-\\d{4}$
patternMessageoptionalstringvalidateoptional(value: InlineEditValue) => string | undefinedYour own check, returns the error message or nothing
currencyoptionalstringFor type currency, default EUR in German and USD in English
decimalsoptionalnumberleveloptional1 | 2 | 3 | 4For type heading
triggeroptional'click' | 'button'click: the text itself opens the field. button: text and a separate Edit button (settings rows).
actionsoptionalbooleanSave and cancel buttons while editing, default on
onBlurActionoptional'save' | 'cancel'What leaving the field does
sizeoptional'sm' | 'md'sm for table cells
alignoptional'start' | 'end'showKeysoptionalbooleanShow a hint with the keys while editing
disabledoptionalbooleannameoptionalstring
Built-in texts
Inline edit writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 16 texts
| Key | English | German |
|---|---|---|
| edit | English Edit | German Bearbeiten |
| save | English Save | German Speichern |
| cancel | English Cancel | German Abbrechen |
| saving | English Saving | German Wird gespeichert |
| saved | English Saved | German Gespeichert |
| failed | English That did not save. Your text is still here, try again. | German Das Speichern hat nicht geklappt. Dein Text ist noch da, versuche es gleich noch einmal. |
| required | English Please fill this in. | German Bitte ausfüllen. |
| tooShort | English Please use at least {n} characters. | German Bitte mindestens {n} Zeichen. |
| tooLong | English Please keep it to {n} characters. | German Bitte höchstens {n} Zeichen. |
| tooSmall | English The smallest value is {n}. | German Der kleinste Wert ist {n}. |
| tooLarge | English The largest value is {n}. | German Der größte Wert ist {n}. |
| notNumber | English Please enter a number. | German Bitte eine Zahl eingeben. |
| pattern | English Please check the format. | German Bitte das Format prüfen. |
| newline | English Enter saves, Shift and Enter start a new line, Escape keeps the old text. | German Enter speichert, Umschalt und Enter beginnen eine neue Zeile, Escape behält den alten Text. |
| keys | English Enter saves, Escape keeps the old value. | German Enter speichert, Escape behält den alten Wert. |
| empty | English Empty | German Leer |
Accessibility
Read from the source of the component.
- Inline edit handles the keys Escape, Enter and Space itself.
- Sets aria-describedby, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the role alert.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 522 lines
'use client';
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Button } from '@/components/ui/button';
import { useShake } from '@/components/ui/input';
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const TEXTS = {
en: {
edit: 'Edit',
save: 'Save',
cancel: 'Cancel',
saving: 'Saving',
saved: 'Saved',
failed: 'That did not save. Your text is still here, try again.',
required: 'Please fill this in.',
tooShort: 'Please use at least {n} characters.',
tooLong: 'Please keep it to {n} characters.',
tooSmall: 'The smallest value is {n}.',
tooLarge: 'The largest value is {n}.',
notNumber: 'Please enter a number.',
pattern: 'Please check the format.',
newline: 'Enter saves, Shift and Enter start a new line, Escape keeps the old text.',
keys: 'Enter saves, Escape keeps the old value.',
empty: 'Empty',
},
de: {
edit: 'Bearbeiten',
save: 'Speichern',
cancel: 'Abbrechen',
saving: 'Wird gespeichert',
saved: 'Gespeichert',
failed: 'Das Speichern hat nicht geklappt. Dein Text ist noch da, versuche es gleich noch einmal.',
required: 'Bitte ausfüllen.',
tooShort: 'Bitte mindestens {n} Zeichen.',
tooLong: 'Bitte höchstens {n} Zeichen.',
tooSmall: 'Der kleinste Wert ist {n}.',
tooLarge: 'Der größte Wert ist {n}.',
notNumber: 'Bitte eine Zahl eingeben.',
pattern: 'Bitte das Format prüfen.',
newline: 'Enter speichert, Umschalt und Enter beginnen eine neue Zeile, Escape behält den alten Text.',
keys: 'Enter speichert, Escape behält den alten Wert.',
empty: 'Leer',
},
};
export type InlineEditType = 'text' | 'heading' | 'textarea' | 'select' | 'number' | 'currency';
export type InlineEditValue = string | number;
/** What onSave may answer: nothing or true (saved), a string (shown as the error), false (the general error) */
export type SaveResult = void | boolean | string;
export interface InlineEditProps {
type?: InlineEditType;
value?: InlineEditValue;
defaultValue?: InlineEditValue;
/** Saves the value. May be async. A string answer is shown as the error and the field stays open. */
onSave?: (value: InlineEditValue) => SaveResult | Promise<SaveResult>;
/** Accessible name, and the visible label with showLabel */
label: string;
showLabel?: boolean;
/** Shown in place of an empty value, e.g. "Add a short bio" */
placeholder?: string;
options?: { value: string; label: string }[];
required?: boolean;
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
step?: number;
/** A regular expression the text must match, e.g. ^[A-Z]{2}-\\d{4}$ */
pattern?: string;
patternMessage?: string;
/** Your own check, returns the error message or nothing */
validate?: (value: InlineEditValue) => string | undefined;
/** For type currency, default EUR in German and USD in English */
currency?: string;
decimals?: number;
/** For type heading */
level?: 1 | 2 | 3 | 4;
/** click: the text itself opens the field. button: text and a separate Edit button (settings rows). */
trigger?: 'click' | 'button';
/** Save and cancel buttons while editing, default on */
actions?: boolean;
/** What leaving the field does */
onBlurAction?: 'save' | 'cancel';
/** sm for table cells */
size?: 'sm' | 'md';
align?: 'start' | 'end';
/** Show a hint with the keys while editing */
showKeys?: boolean;
disabled?: boolean;
name?: string;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
const fill = (s: string, n: number | string) => s.replace('{n}', String(n));
function parseNumber(text: string, locale: Locale) {
const t = text.trim();
if (!t) return null;
const dec = locale === 'de' ? ',' : '.';
const clean = t.replace(new RegExp(`[^0-9\\-${dec === ',' ? ',' : '.'}]`, 'g'), '').replace(dec, '.');
const n = Number.parseFloat(clean);
return Number.isFinite(n) ? n : null;
}
/**
* Click to edit in place after the pattern of a button that turns into a field. Text, heading,
* multiline text, select, number and money, in a form or a table cell. Enter saves, Escape keeps
* the old value, checks show their message under the field, saving shows its state and is
* announced, focus comes back to the text.
* <InlineEdit label="Name" defaultValue="Mia Hoffmann" required onSave={async (v) => save(v)} />
*/
export function InlineEdit({
type = 'text',
value: valueProp,
defaultValue,
onSave,
label,
showLabel,
placeholder,
options,
required,
minLength,
maxLength,
min,
max,
step,
pattern,
patternMessage,
validate,
currency,
decimals,
level = 2,
trigger = 'click',
actions = true,
onBlurAction = 'save',
size = 'md',
align = 'start',
showKeys,
disabled,
name,
labels,
className,
}: InlineEditProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const id = useId();
const numeric = type === 'number' || type === 'currency';
const [inner, setInner] = useState<InlineEditValue>(defaultValue ?? (numeric ? 0 : ''));
const value = valueProp ?? inner;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [pending, setPending] = useState<InlineEditValue | null>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLInputElement & HTMLTextAreaElement & HTMLSelectElement>(null);
const viewRef = useRef<HTMLButtonElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const backToView = useRef(false);
useShake(boxRef, Boolean(error));
const money = (n: number) =>
new Intl.NumberFormat(TAG[locale], { style: 'currency', currency: currency ?? (locale === 'de' ? 'EUR' : 'USD'), minimumFractionDigits: decimals ?? 2, maximumFractionDigits: decimals ?? 2 }).format(n);
const display = (v: InlineEditValue) => {
if (type === 'select') return options?.find((o) => o.value === String(v))?.label ?? String(v);
if (type === 'currency') return money(Number(v));
if (type === 'number') return new Intl.NumberFormat(TAG[locale], { maximumFractionDigits: decimals ?? 2 }).format(Number(v));
return String(v);
};
const toDraft = (v: InlineEditValue) => (numeric ? new Intl.NumberFormat(TAG[locale], { minimumFractionDigits: type === 'currency' ? decimals ?? 2 : 0, maximumFractionDigits: decimals ?? 2, useGrouping: false }).format(Number(v)) : String(v));
const open = () => {
if (disabled || status === 'saving') return;
setDraft(toDraft(value));
setError(null);
setEditing(true);
};
// Focus the field and select its text once it is there
useIsoLayoutEffect(() => {
if (!editing) return;
const f = fieldRef.current;
f?.focus();
if (f && 'select' in f && type !== 'select') f.select();
}, [editing, type]);
// Focus returns to the text after editing
useEffect(() => {
if (!editing && backToView.current) {
backToView.current = false;
(viewRef.current ?? wrapRef.current?.querySelector<HTMLElement>('[data-ie-open]'))?.focus();
}
}, [editing]);
useEffect(() => {
if (status !== 'saved') return;
const timer = window.setTimeout(() => setStatus('idle'), 2400);
return () => window.clearTimeout(timer);
}, [status]);
const check = (raw: string): { ok: true; value: InlineEditValue } | { ok: false; message: string } => {
const text = type === 'textarea' ? raw.replace(/\s+$/, '') : raw.trim();
if (!text && required) return { ok: false, message: t.required };
if (numeric) {
if (!text && !required) return { ok: true, value: 0 };
const n = parseNumber(text, locale);
if (n === null) return { ok: false, message: t.notNumber };
const shown = (x: number) => (type === 'currency' ? money(x) : new Intl.NumberFormat(TAG[locale]).format(x));
if (min !== undefined && n < min) return { ok: false, message: fill(t.tooSmall, shown(min)) };
if (max !== undefined && n > max) return { ok: false, message: fill(t.tooLarge, shown(max)) };
const own = validate?.(n);
return own ? { ok: false, message: own } : { ok: true, value: n };
}
if (minLength !== undefined && text.length < minLength && (text || required)) return { ok: false, message: fill(t.tooShort, minLength) };
if (maxLength !== undefined && text.length > maxLength) return { ok: false, message: fill(t.tooLong, maxLength) };
if (pattern && text && !new RegExp(pattern).test(text)) return { ok: false, message: patternMessage ?? t.pattern };
const own = validate?.(text);
return own ? { ok: false, message: own } : { ok: true, value: text };
};
const cancel = (focusBack = true) => {
backToView.current = focusBack;
setError(null);
setEditing(false);
};
const save = async (raw = draft, focusBack = true) => {
const res = check(raw);
if (!res.ok) {
setError(res.message);
fieldRef.current?.focus();
return;
}
setError(null);
if (res.value === value) return cancel(focusBack);
backToView.current = focusBack;
setEditing(false);
if (!onSave) {
if (valueProp === undefined) setInner(res.value);
setStatus('saved');
return;
}
// The new value shows right away, dimmed, until the save answers
setPending(res.value);
setStatus('saving');
let answer: SaveResult;
try {
answer = await onSave(res.value);
} catch {
answer = false;
}
setPending(null);
if (answer === false || typeof answer === 'string') {
setStatus('idle');
setDraft(raw);
setError(typeof answer === 'string' ? answer : t.failed);
setEditing(true);
return;
}
if (valueProp === undefined) setInner(res.value);
setStatus('saved');
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
cancel();
} else if (e.key === 'Enter' && !(type === 'textarea' && e.shiftKey) && !e.nativeEvent.isComposing) {
e.preventDefault();
save();
}
};
const onBlur = (e: React.FocusEvent) => {
// Moving to the save or cancel button is not leaving
if (wrapRef.current?.contains(e.relatedTarget as Node)) return;
if (onBlurAction === 'cancel') return cancel(false);
// Leaving the page or the window keeps the field as it is
if (!e.relatedTarget && !document.hasFocus()) return;
save(draft, false);
};
const shownValue = pending ?? value;
const isEmpty = String(shownValue) === '' && !numeric;
const Heading = `h${level}` as 'h2';
const text = {
heading: { 1: 'text-h1', 2: 'text-h2', 3: 'text-h3', 4: 'text-lg' }[level] + ' font-semibold tracking-[-0.02em]',
textarea: 'text-[15px] leading-relaxed',
other: size === 'sm' ? 'text-sm' : 'text-[15px]',
};
const typo = type === 'heading' ? text.heading : type === 'textarea' ? text.textarea : text.other;
// One pixel less than the text above it, the border makes up for it and nothing jumps
const pad = trigger === 'button' ? 'px-3 py-2' : size === 'sm' ? 'px-2 py-[5px]' : type === 'heading' ? 'px-2 py-[3px]' : 'px-2 py-[7px]';
const describedBy = cn(error && `${id}-e`, editing && showKeys && `${id}-k`) || undefined;
const affix = type === 'currency' ? currencyAffix(locale, currency) : {};
/* ---------- the field ---------- */
const fieldClass = cn('w-full min-w-0 bg-transparent text-foreground outline-none placeholder:text-subtle-foreground [font:inherit] [letter-spacing:inherit]', align === 'end' && 'text-right');
const field =
type === 'select' ? (
<select
ref={fieldRef}
id={`${id}-f`}
name={name}
value={draft}
aria-invalid={Boolean(error) || undefined}
aria-describedby={describedBy}
onChange={(e) => {
setDraft(e.target.value);
if (!actions) save(e.target.value);
}}
onKeyDown={onKeyDown}
onBlur={onBlur}
className={cn(fieldClass, 'cursor-pointer appearance-none pe-7')}
>
{options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : type === 'textarea' ? (
<textarea
ref={fieldRef}
id={`${id}-f`}
name={name}
value={draft}
rows={3}
maxLength={maxLength ? maxLength + 40 : undefined}
placeholder={placeholder}
aria-invalid={Boolean(error) || undefined}
aria-describedby={describedBy}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onBlur={onBlur}
className={cn(fieldClass, 'block resize-none [field-sizing:content] min-h-[4.5lh]')}
/>
) : (
<input
ref={fieldRef}
id={`${id}-f`}
name={name}
value={draft}
inputMode={numeric ? 'decimal' : undefined}
placeholder={placeholder}
aria-invalid={Boolean(error) || undefined}
aria-describedby={describedBy}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onBlur={onBlur}
className={fieldClass}
/>
);
const count = maxLength && (type === 'text' || type === 'textarea' || type === 'heading') && editing && (
<span aria-hidden className={cn('text-xs tabular-nums', draft.length > maxLength ? 'text-destructive' : 'text-subtle-foreground')}>
{draft.length}/{maxLength}
</span>
);
const editor = (
<div className="grid min-w-0 gap-1.5">
<div
ref={boxRef}
className={cn(
'relative flex min-w-0 items-center gap-1 rounded-md border bg-background shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] transition-[border-color,box-shadow] duration-200',
type === 'textarea' && 'items-start',
error ? 'border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--destructive)_16%,transparent)]' : 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))]',
trigger === 'click' && '-mx-[calc(0.5rem+1px)]',
pad,
typo,
)}
>
{affix.prefix && <span className="shrink-0 text-muted-foreground">{affix.prefix}</span>}
{field}
{affix.suffix && <span className="shrink-0 text-muted-foreground">{affix.suffix}</span>}
{type === 'select' && <Icon name="chevrons-up-down" size={15} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />}
</div>
{(error || count || (actions && trigger === 'click')) && (
<div className={cn('flex min-w-0 items-start gap-3', trigger === 'click' && '-mx-[calc(0.5rem+1px)]')}>
{error ? (
<p id={`${id}-e`} role="alert" className="flex min-w-0 flex-1 items-start gap-1.5 text-[13px] font-normal leading-snug tracking-normal text-destructive">
<Icon name="alert-circle" size={15} className="mt-px" />
{error}
</p>
) : (
<span className="flex-1" />
)}
{count}
{actions && trigger === 'click' && (
<span className="flex shrink-0 gap-1">
<IconAction label={t.cancel} icon="x" onClick={() => cancel()} />
<IconAction label={t.save} icon="check" onClick={() => save()} primary />
</span>
)}
</div>
)}
{showKeys && (
<p id={`${id}-k`} className="text-xs font-normal tracking-normal text-subtle-foreground max-sm:hidden">
{type === 'textarea' ? t.newline : t.keys}
</p>
)}
</div>
);
/* ---------- the text ---------- */
const valueText = (
<span className={cn('min-w-0 whitespace-pre-line break-words', isEmpty && 'text-subtle-foreground', status === 'saving' && 'opacity-60', align === 'end' && 'text-right')}>
{isEmpty ? placeholder ?? t.empty : display(shownValue)}
</span>
);
const statusMark = (
<span aria-hidden className={cn('inline-flex shrink-0 items-center gap-1 self-center text-xs font-medium tracking-normal transition-opacity duration-300', status === 'idle' ? 'hidden' : 'kit-rise', status === 'saved' ? 'text-success-foreground' : 'text-muted-foreground')}>
{status === 'saving' ? <Icon name="loader" size={14} className="animate-spin motion-reduce:animate-none" /> : <Icon name="check" size={14} strokeWidth={2.2} />}
<span className={cn(size === 'sm' && 'sr-only')}>{status === 'saving' ? t.saving : t.saved}</span>
</span>
);
const view =
trigger === 'click' ? (
<button
ref={viewRef}
type="button"
data-ie-open=""
disabled={disabled}
onClick={open}
className={cn(
'group/ie relative -mx-2 flex w-[calc(100%+1rem)] min-w-0 cursor-pointer items-start gap-2 rounded-md text-left text-foreground transition-colors duration-200',
'hover:bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] disabled:cursor-default disabled:hover:bg-transparent',
'outline-none focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-solid focus-visible:outline-ring',
size === 'sm' ? 'min-h-11 px-2 py-1.5 sm:min-h-9' : type === 'heading' ? 'px-2 py-1' : 'min-h-11 px-2 py-2',
align === 'end' && 'justify-end',
typo,
)}
>
{valueText}
<span className="sr-only">, {showLabel ? t.edit : `${label} ${t.edit.toLowerCase()}`}</span>
<Icon
name="edit"
size={size === 'sm' ? 14 : 15}
className={cn('shrink-0 self-center text-subtle-foreground transition-opacity duration-200 group-hover/ie:opacity-100 group-focus-visible/ie:opacity-100 sm:opacity-0', status !== 'idle' && 'hidden', disabled && 'hidden')}
/>
{statusMark}
</button>
) : (
<div className={cn('flex min-w-0 items-center gap-3', typo)}>
<span className="min-w-0 flex-1 text-foreground">{valueText}</span>
{statusMark}
<Button variant="ghost" size="sm" iconLeft="edit" data-ie-open="" disabled={disabled} onClick={open} aria-label={`${label} ${t.edit.toLowerCase()}`} className="shrink-0 -me-2">
{t.edit}
</Button>
</div>
);
return (
<div ref={wrapRef} className={cn('grid min-w-0 gap-1', className)}>
{showLabel && (
editing ? (
<label htmlFor={`${id}-f`} className="text-[13px] font-medium text-muted-foreground">
{label}
</label>
) : (
<span className="text-[13px] font-medium text-muted-foreground">{label}</span>
)
)}
{!showLabel && editing && (
<label htmlFor={`${id}-f`} className="sr-only">
{label}
</label>
)}
{type === 'heading' ? <Heading className="min-w-0">{editing ? editor : view}</Heading> : editing ? editor : view}
{editing && trigger === 'button' && (
<div className="mt-1 flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => cancel()} className="max-sm:flex-1">
{t.cancel}
</Button>
<Button size="sm" onClick={() => save()} className="max-sm:flex-1">
{t.save}
</Button>
</div>
)}
<span aria-live="polite" className="sr-only">
{status === 'saving' ? `${label}, ${t.saving}` : status === 'saved' ? `${label}, ${t.saved}` : ''}
</span>
{name && !editing && <input type="hidden" name={name} value={String(value)} />}
</div>
);
}
function IconAction({ label, icon, onClick, primary }: { label: string; icon: 'x' | 'check'; onClick: () => void; primary?: boolean }) {
return (
<button
type="button"
aria-label={label}
title={label}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={cn(
'relative grid size-7 cursor-pointer place-items-center rounded-md border shadow-[var(--shadow-sm)] transition-colors duration-200 after:absolute after:-inset-2 sm:after:-inset-1',
'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring',
primary ? 'border-primary bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--foreground))]' : 'border-border-strong bg-background text-muted-foreground hover:text-foreground',
)}
>
<Icon name={icon} size={15} strokeWidth={2} />
</button>
);
}
function currencyAffix(locale: Locale, currency?: string): { prefix?: string; suffix?: string } {
const parts = new Intl.NumberFormat(TAG[locale], { style: 'currency', currency: currency ?? (locale === 'de' ? 'EUR' : 'USD') }).formatToParts(1);
const sym = parts.find((p) => p.type === 'currency')?.value ?? '';
return parts.findIndex((p) => p.type === 'currency') < parts.findIndex((p) => p.type === 'integer') ? { prefix: sym } : { suffix: sym };
}Frequent questions
How do I install Inline edit?
With aiw add @ai-website/inline-edit. The command puts components/ui/inline-edit.tsx into your project, the code is yours from then on. It brings along icon, button and input. It needs no further npm packages.
Which variants does Inline edit have?
4 variants, Profile, Heading, Table cells and Settings rows.
Can I translate the texts of Inline edit?
Yes. Inline edit carries 16 texts of its own in English and German, such as “Edit”, “Save” and “Cancel”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Inline edit?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Inline edit with aiw_components and install it with aiw_add @ai-website/inline-edit.