Inline rewrite
Drag a dial and the paragraph rewrites itself in place: shared words glide, the rest blur out and rise in.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/inline-rewrite.jsonOverview
Writing tools hand you a rewrite as a new block of text, and you read both to find what changed. Here a paragraph sits under a dial of versions: Shorter, Original, Longer, or Blunt, Neutral, Warm. Drag the thumb and the paragraph changes in place as you cross each stop, word by word. Words both versions share glide to their new places, the ones that go blur out where they stood, and the new ones rise in one after another and glow for a moment, so you see exactly what moved. The box eases to its new height, the word count rolls and +added −cut against the original roll beside it. Versions you haven’t written yet are written the first time the dial settles on them: a spinner opens in the thumb and a sheen runs over the paragraph until the new words arrive.
States
- drag
- The thumb follows the pointer (no easing); crossing a stop swaps the version straight away and morphs the thumb’s label. Release and it settles on the nearest stop (420ms, a slight overshoot).
- rewrite
- Shared words glide from their old place (FLIP, 480ms); new words rise 0.35em out of a 4px blur, 14ms apart, and glow for 1.8s; words that go blur out and lift (240ms); the paragraph’s height eases (480ms).
- writing
- A version without text, once the dial settles on it: a spinner opens in the thumb, "Rewriting" morphs in and a sheen sweeps the paragraph until onRewrite resolves.
- counts
- The word count and +added −cut against the original roll; on the original itself the counts crossfade to "Original".
- failed
- onRewrite threw: the thumb goes back to the version on screen and "Couldn’t rewrite it" shows for 4 seconds.
Usage
import { InlineRewrite } from "@/components/inline-rewrite";
<InlineRewrite
label="Length"
defaultValue={1}
versions={[
{ label: "Shorter", text: short },
{ label: "Original", text: original },
{ label: "Longer", text: long },
]}
/>Works with
With a model writing the versions
Give only the original; each other stop is written the first time someone lands on it, then kept.
"use client";
import { InlineRewrite } from "@/components/inline-rewrite";
export function ToneDial({ draft }: { draft: string }) {
return (
<InlineRewrite
label="Tone"
defaultValue={1}
versions={[{ label: "Blunt" }, { label: "Neutral", text: draft }, { label: "Warm" }, { label: "Playful" }]}
onRewrite={async ({ label, original, signal }) => {
const res = await fetch("/api/rewrite", {
method: "POST",
body: JSON.stringify({ text: original, tone: label }),
signal,
});
if (!res.ok) throw new Error("rewrite failed");
return (await res.json()).text;
}}
/>
);
}Props
| Prop | Type | Description |
|---|---|---|
| versions | { label: string; text?: string }[] | The dial’s stops in order. Leave text out to have onRewrite write it. |
| value / defaultValue / onValueChange | numberdefault 0 | The chosen version’s index; controlled or not. Changes as the thumb crosses each stop. |
| onRewrite | ({ index, label, current, original, signal }) => Promise<string> | Writes a version that has no text, once the dial settles on it. The result is kept; throw to show it failed. |
| originalIndex | number | The version the +added and −cut counts compare against. Defaults to the one chosen first. |
| label | stringdefault "Rewrite" | The dial’s accessible name, e.g. "Length" or "Tone". |
| highlight | booleandefault true | Let new words glow for a moment after each rewrite. |
Accessibility and motion
- The dial is a slider: arrow keys step through the versions, Home and End go to the ends, and it reads its version’s name as its value.
- Words match exactly, punctuation and case included, so "fresh" and "Fresh" swap rather than glide.
- The paragraph is real text you can select and copy; the words on their way out are hidden from assistive tech, and a polite status says which version is showing and how many words it has.
- Dragging only writes versions once you let go, so sweeping across the dial doesn’t fire a request at every stop.
- Installs Number roll and Text morph. With reduced motion the words change in place and the thumb jumps to its stop.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
The CLI also installs Number roll, Text morph.
Source
›Show the full source of inline-rewrite.tsx (529 lines)
"use client";
import * as React from "react";
import { NumberRoll } from "./number-roll";
import { TextMorph } from "./text-morph";
/* ─────────────────────────────────────────────────────────
* INLINE REWRITE: drag a dial, the paragraph rewrites itself
*
* dial one thumb on a track of versions (Shorter, Original,
* Longer; or Blunt, Neutral, Warm). Drag it, tap the
* track or use the arrow keys; its label morphs and
* it settles onto the nearest stop with a little throw
* rewrite the paragraph changes in place, word by word: the
* words both versions share glide to their new places,
* the ones that go blur out where they stood, the new
* ones rise in one after another and glow for a moment
* so you can see what changed. The box eases to its
* new height
* on demand versions without text are written the first time the
* dial settles on them (onRewrite): a spinner opens in
* the thumb and a sheen runs over the paragraph until
* the new words arrive
* count the word count rolls; +added and −cut against the
* original roll beside it
* ───────────────────────────────────────────────────────── */
export interface RewriteVersion {
label: string;
/** Leave it out to have onRewrite write it the first time it's chosen. */
text?: string;
}
export interface RewriteRequest {
index: number;
label: string;
/** The paragraph as it reads now, and the original version. */
current: string;
original: string;
/** Aborted if the component unmounts first. */
signal: AbortSignal;
}
export interface InlineRewriteProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "defaultValue"> {
versions: RewriteVersion[];
/** The chosen version's index. */
value?: number;
defaultValue?: number;
onValueChange?: (index: number) => void;
/** The version the counts compare against. Defaults to the one chosen first. */
originalIndex?: number;
onRewrite?: (request: RewriteRequest) => Promise<string>;
/** The dial's accessible name, e.g. "Length". */
label?: string;
/** Let new words glow for a moment after each rewrite. */
highlight?: boolean;
}
const EASE = "cubic-bezier(0.16,1,0.3,1)";
const THROW = "cubic-bezier(0.34,1.36,0.64,1)";
const SHEEN =
"[mask-image:linear-gradient(90deg,rgb(0_0_0/0.4)_35%,#000_50%,rgb(0_0_0/0.4)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none]";
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
const split = (text: string) => text.split(/\s+/).filter(Boolean);
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
/** Longest common subsequence table over words: dp[i][j] is the match length of a[i..] and b[j..]. */
function table(a: string[], b: string[]) {
const dp = Array.from({ length: a.length + 1 }, () => new Uint16Array(b.length + 1));
for (let i = a.length - 1; i >= 0; i--)
for (let j = b.length - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
return dp;
}
type Word = { key: number; text: string };
type Leaving = Word & { x: number; y: number; batch: number };
/** Keep the words two texts share (and their keys); everything else is new. */
function morphWords(previous: Word[], next: string[], nextKey: () => number): Word[] {
const a = previous.map((w) => w.text);
const dp = table(a, next);
const out: Word[] = [];
let i = 0;
let j = 0;
while (j < next.length) {
if (i < a.length && a[i] === next[j]) {
out.push(previous[i]);
i++;
j++;
} else if (i < a.length && dp[i + 1][j] >= dp[i][j + 1]) i++;
else out.push({ key: nextKey(), text: next[j++] });
}
return out;
}
/** The paragraph: words as their own boxes so each can move, leave or arrive. */
function Words({ text, reduced, highlight, className }: { text: string; reduced: boolean; highlight: boolean; className: string }) {
const boxRef = React.useRef<HTMLParagraphElement>(null);
const seq = React.useRef(1_000_000); // above the first render's keys
const [words, setWords] = React.useState<Word[]>(() => split(text).map((t, i) => ({ key: i + 1, text: t })));
const [leaving, setLeaving] = React.useState<Leaving[]>([]);
const shown = React.useRef(text);
const flip = React.useRef<{ first: Map<number, DOMRect>; height: number } | null>(null);
const batch = React.useRef(0);
// Before the new words render: note where every word is, and keep the leaving ones where they stood.
React.useLayoutEffect(() => {
if (text === shown.current) return;
shown.current = text;
const next = morphWords(words, split(text), () => ++seq.current);
const box = boxRef.current;
if (!box || reduced) {
setWords(next);
setLeaving([]);
return;
}
const b = box.getBoundingClientRect();
const first = new Map<number, DOMRect>();
box.querySelectorAll<HTMLElement>("[data-k]").forEach((el) => {
// Undo the last glow's reach over the spaces (it never moved anything) so boxes are just the words.
el.style.marginLeft = el.style.paddingLeft = el.style.borderRadius = "";
first.set(Number(el.dataset.k), el.getBoundingClientRect());
});
const keep = new Set(next.map((w) => w.key));
const id = ++batch.current;
const gone: Leaving[] = [];
for (const w of words) {
const r = first.get(w.key);
if (!keep.has(w.key) && r) gone.push({ ...w, x: r.left - b.left, y: r.top - b.top, batch: id });
}
flip.current = { first, height: b.height };
setWords(next);
if (gone.length) {
setLeaving((l) => [...l, ...gone]);
window.setTimeout(() => setLeaving((l) => l.filter((w) => w.batch !== id)), 320);
}
}, [text, reduced, words]);
// After: glide the kept words from where they were, raise the new ones, blur the leaving ones away.
React.useLayoutEffect(() => {
const f = flip.current;
const box = boxRef.current;
if (!f || !box) return;
flip.current = null;
const els = Array.from(box.querySelectorAll<HTMLElement>("[data-k]"));
box.getAnimations().forEach((a) => a.cancel());
els.forEach((el) => el.getAnimations().forEach((a) => a.cancel()));
const height = box.getBoundingClientRect().height;
const rects = els.map((el) => el.getBoundingClientRect());
if (Math.abs(height - f.height) > 0.5) box.animate([{ height: `${f.height}px` }, { height: `${height}px` }], { duration: 480, easing: EASE });
const primary = highlight ? getComputedStyle(box).getPropertyValue("--primary").trim() : "";
const glow = primary ? `color-mix(in oklab, ${primary} 16%, transparent)` : "rgb(0 0 0 / 0.06)";
const isNew = els.map((el) => !f.first.has(Number(el.dataset.k)));
const sameLine = (i: number, j: number) => Math.abs(rects[i].top - rects[j].top) < 2;
let fresh = 0;
els.forEach((el, i) => {
const was = f.first.get(Number(el.dataset.k));
if (was) {
const dx = was.left - rects[i].left;
const dy = was.top - rects[i].top;
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)
el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }], { duration: 480, easing: EASE });
return;
}
const delay = Math.min(90 + fresh++ * 14, 420);
el.animate(
[
{ opacity: 0, transform: "translateY(0.35em)", filter: "blur(4px)" },
{ opacity: 1, transform: "none", filter: "blur(0px)" },
],
{ duration: 380, delay, easing: EASE, fill: "backwards" },
);
if (!highlight) return;
// New words side by side glow as one run: reach back over the space between them, round only the run's ends.
const joinsBefore = i > 0 && isNew[i - 1] && sameLine(i - 1, i);
const joinsAfter = i < els.length - 1 && isNew[i + 1] && sameLine(i, i + 1);
if (joinsBefore) {
const gap = Math.max(0, rects[i].left - rects[i - 1].right);
el.style.marginLeft = `${-gap}px`;
el.style.paddingLeft = `${gap}px`;
}
el.style.borderRadius = `${joinsBefore ? 0 : 4}px ${joinsAfter ? 0 : 4}px ${joinsAfter ? 0 : 4}px ${joinsBefore ? 0 : 4}px`;
el.animate([{ backgroundColor: glow }, { backgroundColor: glow, offset: 0.35 }, { backgroundColor: "transparent" }], {
duration: 1800,
delay,
easing: "ease-out",
fill: "backwards",
});
});
box.querySelectorAll<HTMLElement>("[data-leave]:not([data-out])").forEach((el) => {
el.dataset.out = "";
el.animate(
[
{ opacity: 1, transform: "none", filter: "blur(0px)" },
{ opacity: 0, transform: "translateY(-0.2em) scale(0.96)", filter: "blur(4px)" },
],
{ duration: 240, easing: EASE, fill: "forwards" },
);
});
}, [words, highlight]);
return (
<p ref={boxRef} className={`relative ${className}`}>
{words.map((w, i) => (
<React.Fragment key={w.key}>
{i > 0 && " "}
<span data-k={w.key} className="inline-block">
{w.text}
</span>
</React.Fragment>
))}
{leaving.map((w) => (
<span
key={`gone-${w.batch}-${w.key}`}
data-leave=""
aria-hidden="true"
className="pointer-events-none absolute whitespace-nowrap"
style={{ left: w.x, top: w.y }}
>
{w.text}
</span>
))}
</p>
);
}
/** The dial: stops on a track, one thumb that follows the pointer and settles on the nearest stop. */
function Dial({
labels,
index,
busy,
label,
reduced,
onMove,
}: {
labels: string[];
index: number;
busy: boolean;
label: string;
reduced: boolean;
onMove: (index: number, settled: boolean) => void;
}) {
const trackRef = React.useRef<HTMLDivElement>(null);
const thumbRef = React.useRef<HTMLDivElement>(null);
const measureRef = React.useRef<HTMLDivElement>(null);
const [size, setSize] = React.useState({ width: 0, pad: 48 });
const [ready, setReady] = React.useState(false);
const [drag, setDrag] = React.useState<number | null>(null);
const live = React.useRef({ index, drag: null as number | null });
live.current = { index, drag };
const n = labels.length;
const key = labels.join("|");
// Stops sit far enough in from the ends that the widest label never leaves the track.
React.useLayoutEffect(() => {
const track = trackRef.current;
if (!track) return;
const measure = () => {
const widest = Math.max(0, ...Array.from(measureRef.current?.children ?? []).map((c) => (c as HTMLElement).offsetWidth));
setSize({ width: track.clientWidth, pad: widest / 2 + 4 });
};
measure();
// Only glide once the thumb has been placed, so it doesn't fly in from the edge.
const frame = requestAnimationFrame(() => setReady(true));
const observer = new ResizeObserver(measure);
observer.observe(track);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
};
}, [key]);
const span = Math.max(1, size.width - size.pad * 2);
const stopX = (i: number) => (n < 2 ? size.width / 2 : size.pad + (i * span) / (n - 1));
const nearest = (x: number) => (n < 2 ? 0 : clamp(Math.round(((x - size.pad) / span) * (n - 1)), 0, n - 1));
const x = drag === null ? stopX(index) : clamp(drag, size.pad, size.width - size.pad);
const at = (e: React.PointerEvent) => e.clientX - (trackRef.current?.getBoundingClientRect().left ?? 0);
const follow = (px: number, settled: boolean) => {
const i = nearest(px);
if (i !== live.current.index || settled) onMove(i, settled);
};
const onKey = (e: React.KeyboardEvent) => {
const steps: Record<string, number> = { ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1 };
let next: number | null = null;
if (e.key in steps) next = clamp(index + steps[e.key], 0, n - 1);
if (e.key === "Home") next = 0;
if (e.key === "End") next = n - 1;
if (next === null) return;
e.preventDefault();
if (next !== index) onMove(next, true);
};
return (
<div
ref={trackRef}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
const px = at(e);
setDrag(px);
follow(px, false);
thumbRef.current?.focus({ preventScroll: true });
}}
onPointerMove={(e) => {
if (live.current.drag === null) return;
const px = at(e);
setDrag(px);
follow(px, false);
}}
onPointerUp={() => {
// Settle where the pointer last was; some touch screens report the lift-off point as 0.
const px = live.current.drag;
if (px === null) return;
setDrag(null);
follow(px, true);
}}
onPointerCancel={() => {
if (live.current.drag === null) return;
setDrag(null);
onMove(live.current.index, true);
}}
className="relative h-10 w-full cursor-pointer touch-pan-y select-none rounded-full border border-border bg-muted/40"
>
<div ref={measureRef} aria-hidden="true" className="invisible absolute flex">
{labels.map((l) => (
<span key={l} className="whitespace-nowrap px-3 text-[12.5px] font-medium">
<span className="mr-1.5 inline-block size-3" />
{l}
</span>
))}
</div>
{size.width > 0 &&
labels.map((l, i) => (
<span
key={l}
aria-hidden="true"
className="absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/25"
style={{ left: stopX(i) }}
/>
))}
<div
ref={thumbRef}
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={n - 1}
aria-valuenow={index}
aria-valuetext={labels[index]}
aria-busy={busy || undefined}
onKeyDown={onKey}
className={`absolute top-1/2 flex h-8 -translate-x-1/2 -translate-y-1/2 items-center rounded-full bg-primary px-3 text-[12.5px] font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
drag === null ? "cursor-grab" : "cursor-grabbing"
}`}
style={{
left: x,
opacity: size.width ? 1 : 0,
transition: drag !== null || reduced || !ready ? "none" : `left 420ms ${THROW}`,
}}
>
<span
aria-hidden="true"
className="grid"
style={{
gridTemplateColumns: busy ? "1fr" : "0fr",
opacity: busy ? 1 : 0,
transition: reduced ? "none" : `grid-template-columns 300ms ${EASE}, opacity 200ms ${EASE}`,
}}
>
<span className="overflow-hidden">
<span className="mr-1.5 block size-3 animate-spin rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none" />
</span>
</span>
<TextMorph>{labels[index] ?? ""}</TextMorph>
</div>
</div>
);
}
export function InlineRewrite({
versions,
value,
defaultValue = 0,
onValueChange,
originalIndex,
onRewrite,
label = "Rewrite",
highlight = true,
className = "",
...props
}: InlineRewriteProps) {
const reduced = useReducedMotion();
const [own, setOwn] = React.useState(defaultValue);
const index = clamp(value ?? own, 0, versions.length - 1);
const [first] = React.useState(index);
const original = originalIndex ?? first;
const [made, setMade] = React.useState<Record<number, string>>({});
const textAt = (i: number) => versions[i]?.text ?? made[i];
const [shown, setShown] = React.useState(() => (textAt(index) !== undefined ? index : original));
const [settled, setSettled] = React.useState(true);
const [busy, setBusy] = React.useState<number | null>(null);
const [failed, setFailed] = React.useState(false);
const inflight = React.useRef(new Set<number>());
const controllers = React.useRef(new Set<AbortController>());
const latest = React.useRef({ shown, original, value, onValueChange, onRewrite, versions, made });
latest.current = { shown, original, value, onValueChange, onRewrite, versions, made };
const choose = (i: number, done: boolean) => {
setSettled(done);
if (i === index) return;
setFailed(false);
if (value === undefined) setOwn(i);
onValueChange?.(i);
};
// Show the chosen version as soon as it has words; write it first if it has none (once the dial settles).
React.useEffect(() => {
if (textAt(index) !== undefined) {
setShown(index);
return;
}
const { onRewrite: rewrite } = latest.current;
if (!settled || !rewrite || inflight.current.has(index)) return;
const i = index;
const controller = new AbortController();
controllers.current.add(controller);
inflight.current.add(i);
setBusy(i);
setFailed(false);
const at = (n: number) => latest.current.versions[n]?.text ?? latest.current.made[n] ?? "";
rewrite({ index: i, label: versions[i].label, current: at(latest.current.shown), original: at(latest.current.original), signal: controller.signal })
.then((t) => {
if (!controller.signal.aborted) setMade((m) => ({ ...m, [i]: t }));
})
.catch(() => {
if (controller.signal.aborted) return;
// It didn't work: the dial goes back to what the paragraph says.
setFailed(true);
const back = latest.current.shown;
if (latest.current.value === undefined) setOwn(back);
latest.current.onValueChange?.(back);
})
.finally(() => {
inflight.current.delete(i);
controllers.current.delete(controller);
setBusy((b) => (b === i ? null : b));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [index, settled, made, versions]);
React.useEffect(() => {
if (!failed) return;
const timer = window.setTimeout(() => setFailed(false), 4000);
return () => window.clearTimeout(timer);
}, [failed]);
React.useEffect(() => {
const all = controllers.current;
return () => all.forEach((c) => c.abort());
}, []);
const words = split(textAt(shown) ?? "");
const base = split(textAt(original) ?? "");
const common = React.useMemo(() => table(base, words)[0][0], [base.join(" "), words.join(" ")]); // eslint-disable-line react-hooks/exhaustive-deps
const added = words.length - common;
const cut = base.length - common;
const working = busy !== null && busy === index;
const status = failed ? "Couldn’t rewrite it" : working ? "Rewriting" : "";
return (
<div className={`@container w-full ${className}`} {...props}>
<Dial labels={versions.map((v) => v.label)} index={index} busy={working} label={label} reduced={reduced} onMove={choose} />
<Words
text={textAt(shown) ?? ""}
reduced={reduced}
highlight={highlight}
className={`mt-4 text-[14px] leading-[1.65] text-foreground @md:text-[15px] ${working ? SHEEN : ""}`}
/>
<div className="mt-3 flex items-center gap-2.5 text-[12px] tabular-nums text-muted-foreground">
<span>
<NumberRoll value={words.length} /> <TextMorph>{words.length === 1 ? "word" : "words"}</TextMorph>
</span>
<span aria-hidden="true" className="size-[3px] rounded-full bg-muted-foreground/40" />
{/* Against the original: the counts, or the word "Original" when you're on it. */}
<span className="grid">
<span
className="col-start-1 row-start-1 flex gap-2"
aria-hidden={shown === original}
style={{ opacity: shown === original ? 0 : 1, transition: `opacity 220ms ${EASE}` }}
>
<span className="text-emerald-600 dark:text-emerald-400">
+<NumberRoll value={added} />
</span>
<span className="text-red-600 dark:text-red-400">
−<NumberRoll value={cut} />
</span>
</span>
<span
className="col-start-1 row-start-1"
aria-hidden={shown !== original}
style={{ opacity: shown === original ? 1 : 0, transition: `opacity 220ms ${EASE}` }}
>
Original
</span>
</span>
<span className={`ml-auto ${failed ? "text-red-600 dark:text-red-400" : ""}`}>
<TextMorph>{status}</TextMorph>
</span>
</div>
<p className="sr-only" role="status">
{failed ? "Couldn’t rewrite it. Try again." : `${versions[shown]?.label ?? ""}, ${words.length} words`}
</p>
</div>
);
}
Registry item: /r/inline-rewrite.json