Maksud UI
Components

Editable

An accessible inline editable component for editing text content in place.

API
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
  Editable,
  EditableArea,
  EditableCancel,
  EditableInput,
  EditableLabel,
  EditablePreview,
  EditableSubmit,
  EditableToolbar,
  EditableTrigger,
} from "@/components/ui/editable";
 
export function EditableDemo() {
  return (
    <Editable defaultValue="Click to edit" placeholder="Enter your text here">
      <EditableLabel>Fruit</EditableLabel>
      <EditableArea>
        <EditablePreview />
        <EditableInput />
      </EditableArea>
      <EditableTrigger asChild>
        <Button size="sm" className="w-fit">
          Edit
        </Button>
      </EditableTrigger>
      <EditableToolbar>
        <EditableSubmit asChild>
          <Button size="sm">Save</Button>
        </EditableSubmit>
        <EditableCancel asChild>
          <Button variant="outline" size="sm">
            Cancel
          </Button>
        </EditableCancel>
      </EditableToolbar>
    </Editable>
  );
}

Installation

CLI

npx shadcn@latest add "https://maksud.dev/r/editable"

Manual

Install the following dependencies:

npm install @radix-ui/react-slot

Copy the refs composition utilities into your lib/compose-refs.ts file.

/**
 * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
 */
 
import * as React from "react";
 
type PossibleRef<T> = React.Ref<T> | undefined;
 
/**
 * Set a given ref to a given value
 * This utility takes care of different types of refs: callback refs and RefObject(s)
 */
function setRef<T>(ref: PossibleRef<T>, value: T) {
  if (typeof ref === "function") {
    return ref(value);
  }
 
  if (ref !== null && ref !== undefined) {
    ref.current = value;
  }
}
 
/**
 * A utility to compose multiple refs together
 * Accepts callback refs and RefObject(s)
 */
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  return (node) => {
    let hasCleanup = false;
    const cleanups = refs.map((ref) => {
      const cleanup = setRef(ref, node);
      if (!hasCleanup && typeof cleanup === "function") {
        hasCleanup = true;
      }
      return cleanup;
    });
 
    // React <19 will log an error to the console if a callback ref returns a
    // value. We don't use ref cleanups internally so this will only happen if a
    // user's ref callback returns a value, which we only expect if they are
    // using the cleanup functionality added in React 19.
    if (hasCleanup) {
      return () => {
        for (let i = 0; i < cleanups.length; i++) {
          const cleanup = cleanups[i];
          if (typeof cleanup === "function") {
            cleanup();
          } else {
            setRef(refs[i], null);
          }
        }
      };
    }
  };
}
 
/**
 * A custom hook that composes multiple refs
 * Accepts callback refs and RefObject(s)
 */
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
  return React.useCallback(composeRefs(...refs), refs);
}
 
export { composeRefs, useComposedRefs };

Copy the visually hidden input component into your components/visually-hidden-input.tsx file.

"use client";
 
import * as React from "react";
 
type InputValue = string[] | string;
 
interface VisuallyHiddenInputProps<T = InputValue>
  extends Omit<
    React.InputHTMLAttributes<HTMLInputElement>,
    "value" | "checked" | "onReset"
  > {
  value?: T;
  checked?: boolean;
  control: HTMLElement | null;
  bubbles?: boolean;
}
 
function VisuallyHiddenInput<T = InputValue>(
  props: VisuallyHiddenInputProps<T>,
) {
  const {
    control,
    value,
    checked,
    bubbles = true,
    type = "hidden",
    style,
    ...inputProps
  } = props;
 
  const isCheckInput = React.useMemo(
    () => type === "checkbox" || type === "radio" || type === "switch",
    [type],
  );
  const inputRef = React.useRef<HTMLInputElement>(null);
 
  const prevValueRef = React.useRef<{
    value: T | boolean | undefined;
    previous: T | boolean | undefined;
  }>({
    value: isCheckInput ? checked : value,
    previous: isCheckInput ? checked : value,
  });
 
  const prevValue = React.useMemo(() => {
    const currentValue = isCheckInput ? checked : value;
    if (prevValueRef.current.value !== currentValue) {
      prevValueRef.current.previous = prevValueRef.current.value;
      prevValueRef.current.value = currentValue;
    }
    return prevValueRef.current.previous;
  }, [isCheckInput, value, checked]);
 
  const [controlSize, setControlSize] = React.useState<{
    width?: number;
    height?: number;
  }>({});
 
  React.useLayoutEffect(() => {
    if (!control) {
      setControlSize({});
      return;
    }
 
    setControlSize({
      width: control.offsetWidth,
      height: control.offsetHeight,
    });
 
    if (typeof window === "undefined") return;
 
    const resizeObserver = new ResizeObserver((entries) => {
      if (!Array.isArray(entries) || !entries.length) return;
 
      const entry = entries[0];
      if (!entry) return;
 
      let width: number;
      let height: number;
 
      if ("borderBoxSize" in entry) {
        const borderSizeEntry = entry.borderBoxSize;
        const borderSize = Array.isArray(borderSizeEntry)
          ? borderSizeEntry[0]
          : borderSizeEntry;
        width = borderSize.inlineSize;
        height = borderSize.blockSize;
      } else {
        width = control.offsetWidth;
        height = control.offsetHeight;
      }
 
      setControlSize({ width, height });
    });
 
    resizeObserver.observe(control, { box: "border-box" });
    return () => {
      resizeObserver.disconnect();
    };
  }, [control]);
 
  React.useEffect(() => {
    const input = inputRef.current;
    if (!input) return;
 
    const inputProto = window.HTMLInputElement.prototype;
    const propertyKey = isCheckInput ? "checked" : "value";
    const eventType = isCheckInput ? "click" : "input";
    const currentValue = isCheckInput ? checked : value;
 
    const serializedCurrentValue = isCheckInput
      ? checked
      : typeof value === "object" && value !== null
        ? JSON.stringify(value)
        : value;
 
    const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
 
    const setter = descriptor?.set;
 
    if (prevValue !== currentValue && setter) {
      const event = new Event(eventType, { bubbles });
      setter.call(input, serializedCurrentValue);
      input.dispatchEvent(event);
    }
  }, [prevValue, value, checked, bubbles, isCheckInput]);
 
  const composedStyle = React.useMemo<React.CSSProperties>(() => {
    return {
      ...style,
      ...(controlSize.width !== undefined && controlSize.height !== undefined
        ? controlSize
        : {}),
      border: 0,
      clip: "rect(0 0 0 0)",
      clipPath: "inset(50%)",
      height: "1px",
      margin: "-1px",
      overflow: "hidden",
      padding: 0,
      position: "absolute",
      whiteSpace: "nowrap",
      width: "1px",
    };
  }, [style, controlSize]);
 
  return (
    <input
      type={type}
      {...inputProps}
      ref={inputRef}
      aria-hidden={isCheckInput}
      tabIndex={-1}
      defaultChecked={isCheckInput ? checked : undefined}
      style={composedStyle}
    />
  );
}
 
export { VisuallyHiddenInput };

Copy and paste the following code into your project.

"use client";
 
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
 
const ROOT_NAME = "Editable";
const LABEL_NAME = "EditableLabel";
const AREA_NAME = "EditableArea";
const PREVIEW_NAME = "EditablePreview";
const INPUT_NAME = "EditableInput";
const TRIGGER_NAME = "EditableTrigger";
const TOOLBAR_NAME = "EditableToolbar";
const CANCEL_NAME = "EditableCancel";
const SUBMIT_NAME = "EditableSubmit";
 
type Direction = "ltr" | "rtl";
 
const DirectionContext = React.createContext<Direction | undefined>(undefined);
 
function useDirection(dirProp?: Direction): Direction {
  const contextDir = React.useContext(DirectionContext);
  return dirProp ?? contextDir ?? "ltr";
}
 
function useLazyRef<T>(fn: () => T) {
  const ref = React.useRef<T | null>(null);
 
  if (ref.current === null) {
    ref.current = fn();
  }
 
  return ref as React.RefObject<T>;
}
 
interface StoreState {
  value: string;
  editing: boolean;
}
 
interface Store {
  subscribe: (callback: () => void) => () => void;
  getState: () => StoreState;
  setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
  notify: () => void;
}
 
function createStore(
  listenersRef: React.RefObject<Set<() => void>>,
  stateRef: React.RefObject<StoreState>,
  onValueChange?: (value: string) => void,
  onEditingChange?: (editing: boolean) => void,
): Store {
  const store: Store = {
    subscribe: (cb) => {
      if (listenersRef.current) {
        listenersRef.current.add(cb);
        return () => listenersRef.current?.delete(cb);
      }
      return () => {};
    },
    getState: () =>
      stateRef.current ?? {
        value: "",
        editing: false,
      },
    setState: (key, value) => {
      const state = stateRef.current;
      if (!state || Object.is(state[key], value)) return;
 
      if (key === "value" && typeof value === "string") {
        state.value = value;
        onValueChange?.(value);
      } else if (key === "editing" && typeof value === "boolean") {
        state.editing = value;
        onEditingChange?.(value);
      } else {
        state[key] = value;
      }
 
      store.notify();
    },
    notify: () => {
      if (listenersRef.current) {
        for (const cb of listenersRef.current) {
          cb();
        }
      }
    },
  };
 
  return store;
}
 
const StoreContext = React.createContext<Store | null>(null);
 
function useStoreContext(consumerName: string) {
  const context = React.useContext(StoreContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
function useStore<T>(selector: (state: StoreState) => T): T {
  const store = useStoreContext("useStore");
 
  const getSnapshot = React.useCallback(
    () => selector(store.getState()),
    [store, selector],
  );
 
  return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
 
interface EditableContextValue {
  id: string;
  inputId: string;
  labelId: string;
  defaultValue: string;
  onCancel: () => void;
  onEdit: () => void;
  onSubmit: (value: string) => void;
  onEnterKeyDown?: (event: KeyboardEvent) => void;
  onEscapeKeyDown?: (event: KeyboardEvent) => void;
  dir?: Direction;
  maxLength?: number;
  placeholder?: string;
  triggerMode: "click" | "dblclick" | "focus";
  autosize: boolean;
  disabled?: boolean;
  readOnly?: boolean;
  required?: boolean;
  invalid?: boolean;
}
 
const EditableContext = React.createContext<EditableContextValue | null>(null);
 
function useEditableContext(consumerName: string) {
  const context = React.useContext(EditableContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
type RootElement = React.ComponentRef<typeof EditableRoot>;
 
interface EditableRootProps
  extends Omit<React.ComponentProps<"div">, "onSubmit"> {
  id?: string;
  defaultValue?: string;
  value?: string;
  onValueChange?: (value: string) => void;
  defaultEditing?: boolean;
  editing?: boolean;
  onEditingChange?: (editing: boolean) => void;
  onCancel?: () => void;
  onEdit?: () => void;
  onSubmit?: (value: string) => void;
  onEscapeKeyDown?: (event: KeyboardEvent) => void;
  onEnterKeyDown?: (event: KeyboardEvent) => void;
  dir?: Direction;
  maxLength?: number;
  name?: string;
  placeholder?: string;
  triggerMode?: EditableContextValue["triggerMode"];
  asChild?: boolean;
  autosize?: boolean;
  disabled?: boolean;
  readOnly?: boolean;
  required?: boolean;
  invalid?: boolean;
}
 
function EditableRoot(props: EditableRootProps) {
  const {
    value,
    defaultValue,
    defaultEditing,
    editing,
    onValueChange,
    onEditingChange,
    ...rootProps
  } = props;
 
  const listenersRef = useLazyRef(() => new Set<() => void>());
  const stateRef = useLazyRef<StoreState>(() => ({
    value: value ?? defaultValue ?? "",
    editing: editing ?? defaultEditing ?? false,
  }));
 
  const store = React.useMemo(
    () => createStore(listenersRef, stateRef, onValueChange, onEditingChange),
    [listenersRef, stateRef, onValueChange, onEditingChange],
  );
 
  return (
    <StoreContext.Provider value={store}>
      <EditableRootImpl
        value={value}
        defaultValue={defaultValue}
        editing={editing}
        {...rootProps}
      />
    </StoreContext.Provider>
  );
}
 
function EditableRootImpl(
  props: Omit<EditableRootProps, "onValueChange" | "onEditingChange">,
) {
  const {
    defaultValue = "",
    value: valueProp,
    editing: editingProp,
    onCancel: onCancelProp,
    onEdit: onEditProp,
    onSubmit: onSubmitProp,
    onEscapeKeyDown,
    onEnterKeyDown,
    id: idProp,
    dir: dirProp,
    maxLength,
    name,
    placeholder,
    triggerMode = "click",
    asChild,
    autosize = false,
    disabled,
    required,
    readOnly,
    invalid,
    className,
    ref,
    ...rootProps
  } = props;
 
  const rootId = React.useId();
  const inputId = React.useId();
  const labelId = React.useId();
 
  const id = idProp ?? rootId;
 
  const dir = useDirection(dirProp);
  const store = useStoreContext(ROOT_NAME);
 
  const previousValueRef = React.useRef(defaultValue);
 
  React.useEffect(() => {
    if (valueProp !== undefined) {
      store.setState("value", valueProp);
    }
  }, [valueProp, store]);
 
  React.useEffect(() => {
    if (editingProp !== undefined) {
      store.setState("editing", editingProp);
    }
  }, [editingProp, store]);
 
  const [formTrigger, setFormTrigger] = React.useState<RootElement | null>(
    null,
  );
  const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
  const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
 
  const onCancel = React.useCallback(() => {
    const prevValue = previousValueRef.current;
    store.setState("value", prevValue);
    store.setState("editing", false);
    onCancelProp?.();
  }, [store, onCancelProp]);
 
  const onEdit = React.useCallback(() => {
    const currentValue = store.getState().value;
    previousValueRef.current = currentValue;
    store.setState("editing", true);
    onEditProp?.();
  }, [store, onEditProp]);
 
  const onSubmit = React.useCallback(
    (newValue: string) => {
      store.setState("value", newValue);
      store.setState("editing", false);
      onSubmitProp?.(newValue);
    },
    [store, onSubmitProp],
  );
 
  const contextValue = React.useMemo<EditableContextValue>(
    () => ({
      id,
      inputId,
      labelId,
      defaultValue,
      onSubmit,
      onEdit,
      onCancel,
      onEscapeKeyDown,
      onEnterKeyDown,
      dir,
      maxLength,
      placeholder,
      triggerMode,
      autosize,
      disabled,
      readOnly,
      required,
      invalid,
    }),
    [
      id,
      inputId,
      labelId,
      defaultValue,
      onSubmit,
      onCancel,
      onEdit,
      onEscapeKeyDown,
      onEnterKeyDown,
      dir,
      maxLength,
      placeholder,
      triggerMode,
      autosize,
      disabled,
      required,
      readOnly,
      invalid,
    ],
  );
 
  const value = useStore((state) => state.value);
 
  const RootPrimitive = asChild ? Slot : "div";
 
  return (
    <EditableContext.Provider value={contextValue}>
      <RootPrimitive
        data-slot="editable"
        {...rootProps}
        id={id}
        ref={composedRef}
        className={cn("flex min-w-0 flex-col gap-2", className)}
      />
      {isFormControl && (
        <VisuallyHiddenInput
          type="hidden"
          control={formTrigger}
          name={name}
          value={value}
          disabled={disabled}
          readOnly={readOnly}
          required={required}
        />
      )}
    </EditableContext.Provider>
  );
}
 
interface EditableLabelProps extends React.ComponentProps<"label"> {
  asChild?: boolean;
}
 
function EditableLabel(props: EditableLabelProps) {
  const { asChild, className, children, ref, ...labelProps } = props;
  const context = useEditableContext(LABEL_NAME);
 
  const LabelPrimitive = asChild ? Slot : "label";
 
  return (
    <LabelPrimitive
      data-disabled={context.disabled ? "" : undefined}
      data-invalid={context.invalid ? "" : undefined}
      data-required={context.required ? "" : undefined}
      data-slot="editable-label"
      {...labelProps}
      ref={ref}
      id={context.labelId}
      htmlFor={context.inputId}
      className={cn(
        "font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 data-required:after:ml-0.5 data-required:after:text-destructive data-required:after:content-['*']",
        className,
      )}
    >
      {children}
    </LabelPrimitive>
  );
}
 
interface EditableAreaProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
function EditableArea(props: EditableAreaProps) {
  const { asChild, className, ref, ...areaProps } = props;
  const context = useEditableContext(AREA_NAME);
  const editing = useStore((state) => state.editing);
 
  const AreaPrimitive = asChild ? Slot : "div";
 
  return (
    <AreaPrimitive
      role="group"
      data-disabled={context.disabled ? "" : undefined}
      data-editing={editing ? "" : undefined}
      data-slot="editable-area"
      dir={context.dir}
      {...areaProps}
      ref={ref}
      className={cn(
        "relative inline-block min-w-0 data-disabled:cursor-not-allowed data-disabled:opacity-50",
        className,
      )}
    />
  );
}
 
interface EditablePreviewProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
function EditablePreview(props: EditablePreviewProps) {
  const { asChild, className, ref, ...previewProps } = props;
  const context = useEditableContext(PREVIEW_NAME);
  const value = useStore((state) => state.value);
  const editing = useStore((state) => state.editing);
 
  const onTrigger = React.useCallback(() => {
    if (context.disabled || context.readOnly) return;
    context.onEdit();
  }, [context.onEdit, context.disabled, context.readOnly]);
 
  const onClick = React.useCallback(
    (event: React.MouseEvent<HTMLDivElement>) => {
      previewProps.onClick?.(event);
      if (event.defaultPrevented || context.triggerMode !== "click") return;
 
      onTrigger();
    },
    [previewProps.onClick, onTrigger, context.triggerMode],
  );
 
  const onDoubleClick = React.useCallback(
    (event: React.MouseEvent<HTMLDivElement>) => {
      previewProps.onDoubleClick?.(event);
      if (event.defaultPrevented || context.triggerMode !== "dblclick") return;
 
      onTrigger();
    },
    [previewProps.onDoubleClick, onTrigger, context.triggerMode],
  );
 
  const onFocus = React.useCallback(
    (event: React.FocusEvent<HTMLDivElement>) => {
      previewProps.onFocus?.(event);
      if (event.defaultPrevented || context.triggerMode !== "focus") return;
 
      onTrigger();
    },
    [previewProps.onFocus, onTrigger, context.triggerMode],
  );
 
  const onKeyDown = React.useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      previewProps.onKeyDown?.(event);
      if (event.defaultPrevented) return;
 
      if (event.key === "Enter") {
        const nativeEvent = event.nativeEvent;
        if (context.onEnterKeyDown) {
          context.onEnterKeyDown(nativeEvent);
          if (nativeEvent.defaultPrevented) return;
        }
        onTrigger();
      }
    },
    [previewProps.onKeyDown, onTrigger, context.onEnterKeyDown],
  );
 
  const PreviewPrimitive = asChild ? Slot : "div";
 
  if (editing || context.readOnly) return null;
 
  return (
    <PreviewPrimitive
      role="button"
      aria-disabled={context.disabled || context.readOnly}
      data-empty={!value ? "" : undefined}
      data-disabled={context.disabled ? "" : undefined}
      data-readonly={context.readOnly ? "" : undefined}
      data-slot="editable-preview"
      tabIndex={context.disabled || context.readOnly ? undefined : 0}
      {...previewProps}
      ref={ref}
      onClick={onClick}
      onDoubleClick={onDoubleClick}
      onFocus={onFocus}
      onKeyDown={onKeyDown}
      className={cn(
        "cursor-text truncate rounded-sm border border-transparent py-1 text-base focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-disabled:cursor-not-allowed data-readonly:cursor-default data-empty:text-muted-foreground data-disabled:opacity-50 md:text-sm",
        className,
      )}
    >
      {value || context.placeholder}
    </PreviewPrimitive>
  );
}
 
const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
 
type InputElement = React.ComponentRef<typeof EditableInput>;
 
interface EditableInputProps extends React.ComponentProps<"input"> {
  asChild?: boolean;
  maxLength?: number;
}
 
function EditableInput(props: EditableInputProps) {
  const {
    asChild,
    className,
    disabled,
    readOnly,
    required,
    maxLength,
    ref,
    ...inputProps
  } = props;
  const context = useEditableContext(INPUT_NAME);
  const store = useStoreContext(INPUT_NAME);
  const value = useStore((state) => state.value);
  const editing = useStore((state) => state.editing);
  const inputRef = React.useRef<InputElement>(null);
  const composedRef = useComposedRefs(ref, inputRef);
 
  const isDisabled = disabled || context.disabled;
  const isReadOnly = readOnly || context.readOnly;
  const isRequired = required || context.required;
 
  const onAutosize = React.useCallback(
    (target: InputElement) => {
      if (!context.autosize) return;
 
      if (target instanceof HTMLTextAreaElement) {
        target.style.height = "0";
        target.style.height = `${target.scrollHeight}px`;
      } else {
        target.style.width = "0";
        target.style.width = `${target.scrollWidth + 4}px`;
      }
    },
    [context.autosize],
  );
 
  const onBlur = React.useCallback(
    (event: React.FocusEvent<InputElement>) => {
      if (isDisabled || isReadOnly) return;
 
      inputProps.onBlur?.(event);
      if (event.defaultPrevented) return;
 
      const relatedTarget = event.relatedTarget;
 
      const isAction =
        relatedTarget instanceof HTMLElement &&
        (relatedTarget.closest(`[data-slot="editable-trigger"]`) ||
          relatedTarget.closest(`[data-slot="editable-cancel"]`));
 
      if (!isAction) {
        context.onSubmit(value);
      }
    },
    [value, context.onSubmit, inputProps.onBlur, isDisabled, isReadOnly],
  );
 
  const onChange = React.useCallback(
    (event: React.ChangeEvent<InputElement>) => {
      if (isDisabled || isReadOnly) return;
 
      inputProps.onChange?.(event);
      if (event.defaultPrevented) return;
 
      store.setState("value", event.target.value);
      onAutosize(event.target);
    },
    [store, inputProps.onChange, onAutosize, isDisabled, isReadOnly],
  );
 
  const onKeyDown = React.useCallback(
    (event: React.KeyboardEvent<InputElement>) => {
      if (isDisabled || isReadOnly) return;
 
      inputProps.onKeyDown?.(event);
      if (event.defaultPrevented) return;
 
      if (event.key === "Escape") {
        const nativeEvent = event.nativeEvent;
        if (context.onEscapeKeyDown) {
          context.onEscapeKeyDown(nativeEvent);
          if (nativeEvent.defaultPrevented) return;
        }
        context.onCancel();
      } else if (event.key === "Enter") {
        context.onSubmit(value);
      }
    },
    [
      value,
      context.onSubmit,
      context.onCancel,
      context.onEscapeKeyDown,
      inputProps.onKeyDown,
      isDisabled,
      isReadOnly,
    ],
  );
 
  useIsomorphicLayoutEffect(() => {
    if (!editing || isDisabled || isReadOnly || !inputRef.current) return;
 
    const frameId = window.requestAnimationFrame(() => {
      if (!inputRef.current) return;
 
      inputRef.current.focus();
      inputRef.current.select();
      onAutosize(inputRef.current);
    });
 
    return () => {
      window.cancelAnimationFrame(frameId);
    };
  }, [editing, onAutosize, isDisabled, isReadOnly]);
 
  const InputPrimitive = asChild ? Slot : "input";
 
  if (!editing && !isReadOnly) return null;
 
  return (
    <InputPrimitive
      aria-required={isRequired}
      aria-invalid={context.invalid}
      data-slot="editable-input"
      dir={context.dir}
      disabled={isDisabled}
      readOnly={isReadOnly}
      required={isRequired}
      {...inputProps}
      id={context.inputId}
      aria-labelledby={context.labelId}
      ref={composedRef}
      maxLength={maxLength}
      placeholder={context.placeholder}
      value={value}
      onBlur={onBlur}
      onChange={onChange}
      onKeyDown={onKeyDown}
      className={cn(
        "flex rounded-sm border border-input bg-transparent py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
        context.autosize ? "w-auto" : "w-full",
        className,
      )}
    />
  );
}
 
interface EditableTriggerProps extends React.ComponentProps<"button"> {
  asChild?: boolean;
  forceMount?: boolean;
}
 
function EditableTrigger(props: EditableTriggerProps) {
  const { asChild, forceMount = false, ref, ...triggerProps } = props;
  const context = useEditableContext(TRIGGER_NAME);
  const editing = useStore((state) => state.editing);
 
  const onTrigger = React.useCallback(() => {
    if (context.disabled || context.readOnly) return;
    context.onEdit();
  }, [context.disabled, context.readOnly, context.onEdit]);
 
  const TriggerPrimitive = asChild ? Slot : "button";
 
  if (!forceMount && (editing || context.readOnly)) return null;
 
  return (
    <TriggerPrimitive
      type="button"
      aria-controls={context.id}
      aria-disabled={context.disabled || context.readOnly}
      data-disabled={context.disabled ? "" : undefined}
      data-readonly={context.readOnly ? "" : undefined}
      data-slot="editable-trigger"
      {...triggerProps}
      ref={ref}
      onClick={context.triggerMode === "click" ? onTrigger : undefined}
      onDoubleClick={context.triggerMode === "dblclick" ? onTrigger : undefined}
    />
  );
}
 
interface EditableToolbarProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
  orientation?: "horizontal" | "vertical";
}
 
function EditableToolbar(props: EditableToolbarProps) {
  const {
    asChild,
    className,
    orientation = "horizontal",
    ref,
    ...toolbarProps
  } = props;
  const context = useEditableContext(TOOLBAR_NAME);
 
  const ToolbarPrimitive = asChild ? Slot : "div";
 
  return (
    <ToolbarPrimitive
      role="toolbar"
      aria-controls={context.id}
      aria-orientation={orientation}
      data-slot="editable-toolbar"
      dir={context.dir}
      {...toolbarProps}
      ref={ref}
      className={cn(
        "flex items-center gap-2",
        orientation === "vertical" && "flex-col",
        className,
      )}
    />
  );
}
 
interface EditableCancelProps extends React.ComponentProps<"button"> {
  asChild?: boolean;
}
 
function EditableCancel(props: EditableCancelProps) {
  const { asChild, ref, ...cancelProps } = props;
  const context = useEditableContext(CANCEL_NAME);
  const editing = useStore((state) => state.editing);
 
  const onClick = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      if (context.disabled || context.readOnly) return;
 
      cancelProps.onClick?.(event);
      if (event.defaultPrevented) return;
 
      context.onCancel();
    },
    [cancelProps.onClick, context.onCancel, context.disabled, context.readOnly],
  );
 
  const CancelPrimitive = asChild ? Slot : "button";
 
  if (!editing && !context.readOnly) return null;
 
  return (
    <CancelPrimitive
      type="button"
      aria-controls={context.id}
      data-slot="editable-cancel"
      {...cancelProps}
      onClick={onClick}
      ref={ref}
    />
  );
}
 
interface EditableSubmitProps extends React.ComponentProps<"button"> {
  asChild?: boolean;
}
 
function EditableSubmit(props: EditableSubmitProps) {
  const { asChild, ref, ...submitProps } = props;
  const context = useEditableContext(SUBMIT_NAME);
  const value = useStore((state) => state.value);
  const editing = useStore((state) => state.editing);
 
  const onClick = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      if (context.disabled || context.readOnly) return;
 
      submitProps.onClick?.(event);
      if (event.defaultPrevented) return;
 
      context.onSubmit(value);
    },
    [
      submitProps.onClick,
      context.onSubmit,
      value,
      context.disabled,
      context.readOnly,
    ],
  );
 
  const SubmitPrimitive = asChild ? Slot : "button";
 
  if (!editing && !context.readOnly) return null;
 
  return (
    <SubmitPrimitive
      type="button"
      aria-controls={context.id}
      data-slot="editable-submit"
      {...submitProps}
      ref={ref}
      onClick={onClick}
    />
  );
}
 
export {
  EditableRoot as Editable,
  EditableLabel,
  EditableArea,
  EditablePreview,
  EditableInput,
  EditableTrigger,
  EditableToolbar,
  EditableCancel,
  EditableSubmit,
  //
  EditableRoot as Root,
  EditableLabel as Label,
  EditableArea as Area,
  EditablePreview as Preview,
  EditableInput as Input,
  EditableTrigger as Trigger,
  EditableToolbar as Toolbar,
  EditableCancel as Cancel,
  EditableSubmit as Submit,
  //
  useStore as useEditable,
  //
  type EditableRootProps as EditableProps,
};

Layout

Import the parts, and compose them together.

import * as Editable from "@/components/ui/editable";

<Editable.Root>
  <Editable.Label />
  <Editable.Area>
    <Editable.Preview />
    <Editable.Input />
    <Editable.Trigger />
  </Editable.Area>
  <Editable.Trigger />
  <Editable.Toolbar>
    <Editable.Submit />
    <Editable.Cancel />
  </Editable.Toolbar>
</Editable.Root>

Examples

With Double Click

Trigger edit mode with double click instead of single click.

import * as React from "react";
import { Button } from "@/components/ui/button";
import * as Editable from "@/components/ui/editable";
 
export function EditableDoubleClickDemo() {
  return (
    <div className="flex flex-col gap-4">
      <Editable.Root
        defaultValue="Double click to edit"
        placeholder="Enter your text here"
        triggerMode="dblclick"
      >
        <Editable.Label>Fruit</Editable.Label>
        <Editable.Area>
          <Editable.Preview />
          <Editable.Input />
        </Editable.Area>
        <Editable.Toolbar>
          <Editable.Submit asChild>
            <Button size="sm">Save</Button>
          </Editable.Submit>
          <Editable.Cancel asChild>
            <Button variant="outline" size="sm">
              Cancel
            </Button>
          </Editable.Cancel>
        </Editable.Toolbar>
      </Editable.Root>
    </div>
  );
}

With Autosize

Input that automatically resizes based on content.

import { Button } from "@/components/ui/button";
import * as Editable from "@/components/ui/editable";
 
export function EditableAutosizeDemo() {
  return (
    <Editable.Root
      defaultValue="Adjust the size of the input with the text inside."
      autosize
    >
      <Editable.Label>Autosize editable</Editable.Label>
      <Editable.Area>
        <Editable.Preview className="whitespace-pre-wrap" />
        <Editable.Input />
      </Editable.Area>
      <Editable.Toolbar>
        <Editable.Submit asChild>
          <Button size="sm">Save</Button>
        </Editable.Submit>
        <Editable.Cancel asChild>
          <Button variant="outline" size="sm">
            Cancel
          </Button>
        </Editable.Cancel>
      </Editable.Toolbar>
    </Editable.Root>
  );
}

Todo List

"use client";
 
import { Edit, Trash2 } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import * as Editable from "@/components/ui/editable";
 
interface Todo {
  id: string;
  text: string;
  completed: boolean;
}
 
export function EditableTodoListDemo() {
  const [todos, setTodos] = React.useState<Todo[]>([
    { id: "1", text: "Ollie", completed: false },
    { id: "2", text: "Kickflip", completed: false },
    { id: "3", text: "360 flip", completed: false },
    { id: "4", text: "540 flip", completed: false },
  ]);
 
  function onDeleteTodo(id: string) {
    setTodos((prev) => prev.filter((todo) => todo.id !== id));
  }
 
  function onToggleTodo(id: string) {
    setTodos((prev) =>
      prev.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo,
      ),
    );
  }
 
  function onUpdateTodo(id: string, newText: string) {
    setTodos((prev) =>
      prev.map((todo) => (todo.id === id ? { ...todo, text: newText } : todo)),
    );
  }
 
  return (
    <div className="flex w-full min-w-0 flex-col gap-2">
      <span className="font-semibold text-lg">Tricks to learn</span>
      {todos.map((todo) => (
        <div
          key={todo.id}
          className="flex items-center gap-2 rounded-lg border bg-card px-4 py-2"
        >
          <Checkbox
            checked={todo.completed}
            onCheckedChange={() => onToggleTodo(todo.id)}
          />
          <Editable.Root
            key={todo.id}
            defaultValue={todo.text}
            onSubmit={(value) => onUpdateTodo(todo.id, value)}
            className="flex flex-1 flex-row items-center gap-1.5"
          >
            <Editable.Area className="flex-1">
              <Editable.Preview
                className={cn("w-full rounded-md px-1.5 py-1", {
                  "text-muted-foreground line-through": todo.completed,
                })}
              />
              <Editable.Input className="px-1.5 py-1" />
            </Editable.Area>
            <Editable.Trigger asChild>
              <Button variant="ghost" size="icon" className="size-7">
                <Edit />
              </Button>
            </Editable.Trigger>
          </Editable.Root>
          <Button
            variant="ghost"
            size="icon"
            className="size-7 text-destructive"
            onClick={() => onDeleteTodo(todo.id)}
          >
            <Trash2 />
          </Button>
        </div>
      ))}
    </div>
  );
}

With Form

Control the editable component in a form.

"use client";
 
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import {
  Editable,
  EditableArea,
  EditableCancel,
  EditableInput,
  EditableLabel,
  EditablePreview,
  EditableSubmit,
  EditableToolbar,
  EditableTrigger,
} from "@/components/ui/editable";
 
const formSchema = z.object({
  name: z
    .string()
    .min(2, "Name must be at least 2 characters")
    .max(50, "Name must be less than 50 characters"),
  title: z
    .string()
    .min(3, "Title must be at least 3 characters")
    .max(100, "Title must be less than 100 characters"),
});
 
type FormSchema = z.infer<typeof formSchema>;
 
export function EditableFormDemo() {
  const form = useForm<FormSchema>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      name: "Rodney Mullen",
      title: "Skateboarder",
    },
  });
 
  const onSubmit = React.useCallback((input: FormSchema) => {
    toast.success(
      <pre className="w-full">{JSON.stringify(input, null, 2)}</pre>,
    );
  }, []);
 
  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className="flex w-full flex-col gap-2 rounded-md border p-4 shadow-sm"
      >
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormControl>
                <Editable
                  defaultValue={field.value}
                  onSubmit={field.onChange}
                  invalid={
                    !!form.formState.errors.name &&
                    !!form.formState.touchedFields.name
                  }
                >
                  <FormLabel asChild>
                    <EditableLabel>Name</EditableLabel>
                  </FormLabel>
                  <div className="flex items-start gap-4">
                    <EditableArea className="flex-1">
                      <EditablePreview />
                      <EditableInput />
                    </EditableArea>
                    <EditableTrigger asChild>
                      <Button type="button" variant="outline" size="sm">
                        Edit
                      </Button>
                    </EditableTrigger>
                  </div>
                  <EditableToolbar>
                    <EditableSubmit asChild>
                      <Button type="button" size="sm">
                        Save
                      </Button>
                    </EditableSubmit>
                    <EditableCancel asChild>
                      <Button type="button" variant="outline" size="sm">
                        Cancel
                      </Button>
                    </EditableCancel>
                  </EditableToolbar>
                  <FormMessage />
                </Editable>
              </FormControl>
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="title"
          render={({ field }) => (
            <FormItem>
              <FormControl>
                <Editable
                  defaultValue={field.value}
                  onSubmit={field.onChange}
                  invalid={
                    !!form.formState.errors.title &&
                    !!form.formState.touchedFields.title
                  }
                >
                  <FormLabel asChild>
                    <EditableLabel>Title</EditableLabel>
                  </FormLabel>
                  <div className="flex items-start gap-4">
                    <EditableArea className="flex-1">
                      <EditablePreview />
                      <EditableInput />
                    </EditableArea>
                    <EditableTrigger asChild>
                      <Button type="button" variant="outline" size="sm">
                        Edit
                      </Button>
                    </EditableTrigger>
                  </div>
                  <EditableToolbar>
                    <EditableSubmit asChild>
                      <Button type="button" size="sm">
                        Save
                      </Button>
                    </EditableSubmit>
                    <EditableCancel asChild>
                      <Button type="button" variant="outline" size="sm">
                        Cancel
                      </Button>
                    </EditableCancel>
                  </EditableToolbar>
                  <FormMessage />
                </Editable>
              </FormControl>
            </FormItem>
          )}
        />
        <div className="flex w-fit gap-2 self-end">
          <Button
            type="button"
            variant="outline"
            className="w-fit"
            onClick={() => form.reset()}
          >
            Reset
          </Button>
          <Button type="submit" className="w-fit">
            Update
          </Button>
        </div>
      </form>
    </Form>
  );
}

API Reference

Root

The main container component for editable functionality.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/editable.ts",
  "name": "RootProps"
}

Label

The label component for the editable field.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/editable.ts",
  "name": "LabelProps"
}
Data AttributeValue
[data-disabled]Present when the editable field is disabled
[data-invalid]Present when the editable field is invalid
[data-required]Present when the editable field is required

Area

Container for the preview and input components.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/editable.ts",
  "name": "AreaProps"
}
Data AttributeValue
[data-disabled]Present when the editable field is disabled
[data-editing]Present when the field is in edit mode

Preview

The preview component that displays the current value.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/editable.ts",
  "name": "PreviewProps"
}
Data AttributeValue
[data-empty]Present when the field has no value
[data-disabled]Present when the editable field is disabled
[data-readonly]Present when the field is read-only

Input

The input component for editing the value.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/editable.ts",
  "name": "InputProps"
}

Trigger

Button to trigger edit mode.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/editable.ts",
  "name": "TriggerProps"
}
Data AttributeValue
[data-disabled]Present when the editable field is disabled
[data-readonly]Present when the field is read-only

Toolbar

Container for action buttons.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/editable.ts",
  "name": "ToolbarProps"
}

Submit

Button to submit changes.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/editable.ts",
  "name": "SubmitProps"
}

Cancel

Button to cancel changes.

AutoTypeTable temporarily disabled - debugging stack overflow issue

{
  "path": "./types/docs/editable.ts",
  "name": "CancelProps"
}

Accessibility

Keyboard Interactions

KeyDescription
EnterSubmits the current value when in edit mode.
EscapeCancels editing and reverts to the previous value.
TabMoves focus to the next focusable element.