Skip to content
UI Kit
Accent color

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.

4 variantsOne of 11 components in Forms and inputs

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

ProfileNew in 1.2

A profile where every value edits in place. The name saves after a short request, the role saves on choice, the address is checked and the server can refuse it, the about text counts its characters.

HeadingNew in 1.2

A document title that stays a real heading. The field takes the size of the text, so nothing jumps, and the description below edits as longer text.

Table cellsNew in 1.2

Cells of a price list that edit in place, numbers right aligned, money in the format of the language, status as a choice. Every change counts into the total below.

Settings rowsNew in 1.2

Rows with a label and a separate edit button, for values people change rarely and on purpose. Save and cancel stand as words, the format check explains itself.

Details

What Inline edit brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/inline-edit.tsx
Brings along
utilskiticonbuttoninput
npm packages
None
Languages
English and German, 16 texts of its own
Studio
Adjust in code

Props

The InlineEditProps interface from components/ui/inline-edit.tsx, with the comments from the code.

  • typeoptional
    InlineEditType

    textheadingtextareaselectnumbercurrency

  • valueoptional
    InlineEditValue
  • defaultValueoptional
    InlineEditValue
  • onSaveoptional
    (value: InlineEditValue) => SaveResult | Promise<SaveResult>

    Saves the value. May be async. A string answer is shown as the error and the field stays open.

  • labelrequired
    string

    Accessible name, and the visible label with showLabel

  • showLabeloptional
    boolean
  • placeholderoptional
    string

    Shown in place of an empty value, e.g. "Add a short bio"

  • optionsoptional
    { value: string; label: string }[]
  • requiredoptional
    boolean
  • minLengthoptional
    number
  • maxLengthoptional
    number
  • minoptional
    number
  • maxoptional
    number
  • stepoptional
    number
  • patternoptional
    string

    A regular expression the text must match, e.g. ^[A-Z]{2}-\\d{4}$

  • patternMessageoptional
    string
  • validateoptional
    (value: InlineEditValue) => string | undefined

    Your own check, returns the error message or nothing

  • currencyoptional
    string

    For type currency, default EUR in German and USD in English

  • decimalsoptional
    number
  • leveloptional
    1 | 2 | 3 | 4

    For type heading

  • triggeroptional
    'click' | 'button'

    click: the text itself opens the field. button: text and a separate Edit button (settings rows).

  • actionsoptional
    boolean

    Save 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'
  • showKeysoptional
    boolean

    Show a hint with the keys while editing

  • disabledoptional
    boolean
  • nameoptional
    string

Built-in texts

Inline edit writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 16 texts
editEnglish EditGerman Bearbeiten
saveEnglish SaveGerman Speichern
cancelEnglish CancelGerman Abbrechen
savingEnglish SavingGerman Wird gespeichert
savedEnglish SavedGerman Gespeichert
failedEnglish 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.
requiredEnglish Please fill this in.German Bitte ausfüllen.
tooShortEnglish Please use at least {n} characters.German Bitte mindestens {n} Zeichen.
tooLongEnglish Please keep it to {n} characters.German Bitte höchstens {n} Zeichen.
tooSmallEnglish The smallest value is {n}.German Der kleinste Wert ist {n}.
tooLargeEnglish The largest value is {n}.German Der größte Wert ist {n}.
notNumberEnglish Please enter a number.German Bitte eine Zahl eingeben.
patternEnglish Please check the format.German Bitte das Format prüfen.
newlineEnglish 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.
keysEnglish Enter saves, Escape keeps the old value.German Enter speichert, Escape behält den alten Wert.
emptyEnglish EmptyGerman 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
components/ui/inline-edit.tsx
'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.

Often searched

Jump to

Copied to the clipboard