AI Patterns

text

Refinement Controls

One-tap adjustments (Rephrase, Shorten, More formal, Bulletize…) and a free-form instruction field docked under an AI output, with every result kept as a steppable version.

Original draft
Onboarding is the design of early success. It helps new users reach their first meaningful outcome quickly by combining motivation, ability, timely prompts, learning scaffolds, and trust, so that the product feels worth coming back to after the very first session.
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import {
  AlignJustify,
  AlignLeft,
  ArrowUp,
  Briefcase,
  ChevronLeft,
  ChevronRight,
  ChevronUp,
  FileText,
  List,
  PenLine,
  WandSparkles,
} from "lucide-react";

import { cn } from "@/lib/utils";

export interface Refinement {
  /** Instruction passed to `onRefine`, e.g. "Shorten". */
  label: string;
  /** Present-progressive form shown while the request runs, e.g. "Shortening". */
  progress: string;
  icon: React.ComponentType<{ className?: string }>;
}

export interface RefinementControlsProps {
  /** The first AI draft — version 1. */
  text: string;
  onRefine: (text: string, instruction: string) => string | Promise<string>;
  /** Refinements shown as always-visible chips. */
  primary?: Refinement[];
  /** Refinements tucked into the "More" menu. */
  more?: Refinement[];
  placeholder?: string;
  className?: string;
}

export const DEFAULT_PRIMARY: Refinement[] = [
  { label: "Match writing style", progress: "Matching your style", icon: PenLine },
  { label: "Rephrase", progress: "Rephrasing", icon: WandSparkles },
];

export const DEFAULT_MORE: Refinement[] = [
  { label: "Shorten", progress: "Shortening", icon: AlignLeft },
  { label: "Elaborate", progress: "Elaborating", icon: AlignJustify },
  { label: "More formal", progress: "Making it more formal", icon: Briefcase },
  { label: "Bulletize", progress: "Bulletizing", icon: List },
  { label: "Summarize", progress: "Summarizing", icon: FileText },
];

interface Version {
  text: string;
  /** The refinement that produced this version; null for the original draft. */
  label: string | null;
}

export function RefinementControls({
  text,
  onRefine,
  primary = DEFAULT_PRIMARY,
  more = DEFAULT_MORE,
  placeholder = "Format this by…",
  className,
}: RefinementControlsProps) {
  const [versions, setVersions] = React.useState<Version[]>([{ text, label: null }]);
  const [index, setIndex] = React.useState(0);
  const [pending, setPending] = React.useState<string | null>(null);
  const [instruction, setInstruction] = React.useState("");

  const current = versions[index];
  const busy = pending !== null;

  async function refine(label: string, progress: string) {
    if (busy) return;
    setPending(progress);
    try {
      const result = await onRefine(current.text, label);
      // `busy` blocks concurrent refinements, so `versions` is current here.
      setVersions((prev) => [...prev, { text: result, label }]);
      setIndex(versions.length);
    } finally {
      setPending(null);
    }
  }

  function submitInstruction() {
    const trimmed = instruction.trim();
    if (!trimmed || busy) return;
    setInstruction("");
    refine(trimmed, "Applying your instruction");
  }

  return (
    <div className={cn("w-full rounded-2xl border bg-card shadow-sm", className)}>
      <div className="flex items-center justify-between gap-3 px-4 pt-3">
        <span className="truncate text-xs text-muted-foreground">
          {current.label ?? "Original draft"}
        </span>
        {versions.length > 1 && (
          <VersionStepper
            index={index}
            total={versions.length}
            disabled={busy}
            onPrev={() => setIndex((i) => Math.max(0, i - 1))}
            onNext={() => setIndex((i) => Math.min(versions.length - 1, i + 1))}
          />
        )}
      </div>

      <div className="relative px-4 pb-4 pt-2">
        <AnimatePresence mode="wait" initial={false}>
          <motion.div
            key={index}
            aria-busy={busy}
            initial={{ opacity: 0, y: 4 }}
            animate={{ opacity: busy ? 0.45 : 1, y: 0 }}
            exit={{ opacity: 0, y: -4 }}
            transition={{ duration: 0.18 }}
            className="whitespace-pre-line text-sm leading-relaxed text-foreground/90"
          >
            {current.text}
          </motion.div>
        </AnimatePresence>
        <div role="status" aria-live="polite" className="h-0">
          <AnimatePresence>
            {pending && (
              <motion.span
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                className="absolute bottom-1.5 left-4 flex items-center gap-1.5 text-xs text-muted-foreground"
              >
                <WandSparkles className="size-3.5 animate-pulse" aria-hidden />
                {pending}…
              </motion.span>
            )}
          </AnimatePresence>
        </div>
      </div>

      <div className="rounded-b-2xl border-t bg-muted/30">
        <div className="flex flex-wrap items-center gap-1 px-2 pt-2" role="toolbar" aria-label="Refine output">
          {primary.map((r) => (
            <ChipButton key={r.label} icon={r.icon} disabled={busy} onClick={() => refine(r.label, r.progress)}>
              {r.label}
            </ChipButton>
          ))}
          {more.length > 0 && (
            <MoreMenu items={more} disabled={busy} onSelect={(r) => refine(r.label, r.progress)} />
          )}
        </div>

        <form
          className="flex items-center gap-2 px-2 pb-2 pt-1.5"
          onSubmit={(e) => {
            e.preventDefault();
            submitInstruction();
          }}
        >
          <input
            value={instruction}
            onChange={(e) => setInstruction(e.target.value)}
            disabled={busy}
            placeholder={placeholder}
            aria-label="Describe how to refine the output"
            className="min-w-0 flex-1 rounded-full bg-transparent px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
          />
          <button
            type="submit"
            aria-label="Apply instruction"
            disabled={busy || !instruction.trim()}
            className="flex size-8 shrink-0 items-center justify-center rounded-full bg-foreground text-background transition-opacity disabled:opacity-25"
          >
            <ArrowUp className="size-4" aria-hidden />
          </button>
        </form>
      </div>
    </div>
  );
}

function ChipButton({
  icon: Icon,
  disabled,
  onClick,
  children,
}: {
  icon: React.ComponentType<{ className?: string }>;
  disabled?: boolean;
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      disabled={disabled}
      onClick={onClick}
      className="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm text-foreground/80 transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
    >
      <Icon className="size-4" aria-hidden />
      {children}
    </button>
  );
}

function MoreMenu({
  items,
  disabled,
  onSelect,
}: {
  items: Refinement[];
  disabled?: boolean;
  onSelect: (r: Refinement) => void;
}) {
  const [open, setOpen] = React.useState(false);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const menuId = React.useId();

  React.useEffect(() => {
    if (!open) return;
    itemRefs.current[0]?.focus();
    function handlePointerDown(e: PointerEvent) {
      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
    }
    document.addEventListener("pointerdown", handlePointerDown);
    return () => document.removeEventListener("pointerdown", handlePointerDown);
  }, [open]);

  function close(refocus: boolean) {
    setOpen(false);
    if (refocus) triggerRef.current?.focus();
  }

  function handleMenuKeyDown(e: React.KeyboardEvent) {
    const focused = itemRefs.current.findIndex((el) => el === document.activeElement);
    const move = (to: number) => itemRefs.current[(to + items.length) % items.length]?.focus();
    if (e.key === "ArrowDown") {
      e.preventDefault();
      move(focused + 1);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      move(focused - 1);
    } else if (e.key === "Home") {
      e.preventDefault();
      move(0);
    } else if (e.key === "End") {
      e.preventDefault();
      move(items.length - 1);
    } else if (e.key === "Escape") {
      e.preventDefault();
      close(true);
    } else if (e.key === "Tab") {
      close(false);
    }
  }

  return (
    <div ref={rootRef} className="relative">
      <button
        ref={triggerRef}
        type="button"
        disabled={disabled}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? menuId : undefined}
        onClick={() => setOpen((v) => !v)}
        onKeyDown={(e) => {
          if (e.key === "ArrowUp" || e.key === "ArrowDown") {
            e.preventDefault();
            setOpen(true);
          }
        }}
        className={cn(
          "flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm text-foreground/80 transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50",
          open && "bg-accent text-foreground"
        )}
      >
        More
        <ChevronUp className={cn("size-3.5 transition-transform", !open && "rotate-180")} aria-hidden />
      </button>

      <AnimatePresence>
        {open && (
          <motion.div
            id={menuId}
            role="menu"
            aria-label="More refinements"
            onKeyDown={handleMenuKeyDown}
            initial={{ opacity: 0, y: 4, scale: 0.98 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 4, scale: 0.98 }}
            transition={{ duration: 0.14 }}
            className="absolute bottom-full left-0 z-50 mb-2 w-52 origin-bottom-left overflow-hidden rounded-xl border bg-popover py-1 shadow-lg"
          >
            {items.map((r, i) => (
              <button
                key={r.label}
                ref={(el) => {
                  itemRefs.current[i] = el;
                }}
                type="button"
                role="menuitem"
                tabIndex={-1}
                onClick={() => {
                  close(true);
                  onSelect(r);
                }}
                className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-foreground/90 outline-none hover:bg-accent focus-visible:bg-accent"
              >
                <r.icon className="size-4 text-muted-foreground" aria-hidden />
                {r.label}
              </button>
            ))}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function VersionStepper({
  index,
  total,
  disabled,
  onPrev,
  onNext,
}: {
  index: number;
  total: number;
  disabled?: boolean;
  onPrev: () => void;
  onNext: () => void;
}) {
  return (
    <div className="flex shrink-0 items-center gap-0.5 text-xs text-muted-foreground">
      <button
        type="button"
        aria-label="Previous version"
        disabled={disabled || index === 0}
        onClick={onPrev}
        className="flex size-6 items-center justify-center rounded-full transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
      >
        <ChevronLeft className="size-3.5" aria-hidden />
      </button>
      <span className="tabular-nums" aria-live="polite">
        {index + 1} / {total}
      </span>
      <button
        type="button"
        aria-label="Next version"
        disabled={disabled || index === total - 1}
        onClick={onNext}
        className="flex size-6 items-center justify-center rounded-full transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
      >
        <ChevronRight className="size-3.5" aria-hidden />
      </button>
    </div>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Refinement Controls

## Summary
A row of one-tap adjustments — "Rephrase", "Shorten", "More formal", "Bulletize" — plus a free-form "Format this by…" field, attached directly to an AI output. Users tend to stop at the first output because they don't know it can be improved, or don't know how to ask. Refinement controls make iteration visible and cheap: they name the most useful next moves and turn each into a single click, while every result is kept as a version the user can step back through.

## When to use
- When AI output is a starting point that usually benefits from adjustment — drafted prose, summaries, rewrites, generated copy.
- When users are new to AI features and don't yet know what they can ask for; the chips teach the vocabulary of refinement by example.
- When the common adjustments are predictable (length, tone, structure) and can be named in one or two words.

## When not to use
- When the output is deterministic and has a single correct answer (a unit conversion, a lookup, a calculation) — offering "Rephrase" or "More formal" implies the answer is a matter of taste.
- When the edit is scoped to part of the output — use Selection Actions so the change is anchored to the highlighted passage rather than rewriting everything.
- When the user is mid-conversation and a follow-up message is the natural way to ask for changes; a chat composer already serves as the free-form field.

## Anatomy
- The output itself, with a small header naming which version is showing ("Original draft", or the refinement that produced it) and, once there's more than one version, a "‹ 2 / 3 ›" stepper.
- A refinement bar docked under the output:
  - Primary chips, each with an icon and a short label — the two or three adjustments most people want (e.g. "Match writing style", "Rephrase").
  - A "More" chip that opens a menu upward with the long tail of adjustments (Shorten, Elaborate, More formal, Bulletize, Summarize), each with its own icon.
  - A free-form input with an example-led placeholder ("Format this by…") and a send button for anything the presets don't cover.
- A transient status line ("Shortening…") shown over the output while a refinement runs.

## Behavior
- Selecting a chip, a menu item, or submitting the input applies that instruction to the version currently shown, not always to the original — refinements compound (Shorten, then More formal).
- While a refinement runs, the output dims, the status line names the action in progress, and all controls are disabled so requests can't race each other.
- Each result is appended as a new version and becomes the visible one. Earlier versions are never overwritten; the stepper moves between them. Refining from an earlier version still appends to the end of the history rather than discarding later versions.
- The "More" menu opens above its trigger (the bar sits at the bottom of the surface), closes on selection, outside click, Escape, or Tab, and returns focus to the trigger when closed by keyboard or selection.
- The send button stays disabled until the input has non-whitespace text; submitting clears the field.
- The version transition is a short crossfade so the user perceives that the text changed, without slowing down rapid iteration.

## Content guidelines
- Chip labels are one or two words, verbs or comparatives: "Shorten", "Elaborate", "More formal". They should describe the result, not the mechanism.
- Promote to primary chips only the adjustments with the highest usage; put the rest under "More". Five to seven total presets is plenty — beyond that the menu becomes a settings panel.
- The input placeholder should model how to write an instruction ("Format this by…", "Make it…"), not just say "Type here".
- The in-progress status uses the present progressive of the chosen action ("Making it more formal…") so users can confirm the click registered what they meant.
- Label each version with the refinement that made it, so stepping back reads as a history of decisions rather than anonymous numbers.

## Accessibility
- The chip row has `role="toolbar"` with an `aria-label`; every chip is a real `<button>` with a visible text label (icons are `aria-hidden`).
- "More" is a menu button: `aria-haspopup="menu"`, `aria-expanded`, and `aria-controls` while open. The menu has `role="menu"`, items have `role="menuitem"`, focus moves to the first item on open, Arrow keys / Home / End move between items, and Escape closes and returns focus to the trigger.
- The output region sets `aria-busy` while a refinement is generating, and the status line lives in an `aria-live="polite"` region so screen reader users hear "Shortening…" and know when it finishes.
- The free-form input has its own `aria-label` since its only visible label is the placeholder; the icon-only send and stepper buttons have `aria-label`s.

## Related patterns
- Selection Actions — the same idea scoped to a highlighted passage; pair them so users can refine the whole output or just a sentence.
- Chat Bubble with Actions — its version stepper and regenerate action cover re-running the same prompt; Refinement Controls change the prompt in a named direction instead.
- Follow-Up List — suggests the next question to ask; Refinement Controls suggest the next change to make to the current answer.