"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export type MentionTrigger = "@" | "#";
export interface MentionItem {
id: string;
label: string;
/** Secondary text, e.g. a handle or a count. */
description?: string;
}
export interface Mention {
id: string;
label: string;
trigger: MentionTrigger;
/** Range of the token (e.g. "@Ada Lovelace") in `text`, end exclusive. */
start: number;
end: number;
}
export interface MentionValue {
/** Plain text, with mention tokens written as trigger + label. */
text: string;
/** Mentions ordered by position. */
mentions: Mention[];
}
export interface MentionInputProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue" | "children"> {
/** Controlled value. */
value?: MentionValue;
/** Initial value when uncontrolled. */
defaultValue?: MentionValue;
onChange?: (value: MentionValue) => void;
/** People offered after "@" (filtered locally when `onSearch` is not set). */
users?: MentionItem[];
/** Tags offered after "#" (filtered locally when `onSearch` is not set). */
tags?: MentionItem[];
/** Async source for suggestions; replaces local filtering. The signal aborts superseded queries. */
onSearch?: (query: string, trigger: MentionTrigger, signal: AbortSignal) => Promise<MentionItem[]>;
/** Enable "#" tags alongside "@" mentions. */
allowTags?: boolean;
/** Delay before `onSearch` runs, in ms. */
debounce?: number;
/** Visible label for the textarea. */
label?: React.ReactNode;
/** Helper text linked with aria-describedby. */
hint?: React.ReactNode;
placeholder?: string;
rows?: number;
/** Optional character limit, shown as a counter. */
maxLength?: number;
/** Mention highlight, caret and focus colour (any CSS colour). */
accent?: string;
disabled?: boolean;
}
type ActiveTrigger = { trigger: MentionTrigger; start: number; query: string };
type Results = { key: string; items: MentionItem[]; error: boolean };
const EMPTY: MentionValue = { text: "", mentions: [] };
const QUERY_CHAR = /[\p{L}\p{N}_.\-]/u;
const POPUP_WIDTH = 264;
/** CSS properties copied to the mirror so it wraps text exactly like the textarea. */
const MIRROR_PROPS = [
"direction", "box-sizing", "width", "height", "overflow-x", "overflow-y", "scrollbar-gutter",
"border-top-width", "border-right-width", "border-bottom-width", "border-left-width", "border-style",
"padding-top", "padding-right", "padding-bottom", "padding-left",
"font-style", "font-variant", "font-weight", "font-stretch", "font-size", "font-size-adjust", "line-height", "font-family",
"font-feature-settings", "font-variation-settings",
"text-align", "text-transform", "text-indent", "letter-spacing", "word-spacing", "tab-size",
];
/**
* Pixel position of a character inside a textarea (mirror-div technique): an invisible div copies
* the textarea's box and type styles, holds the text up to `position`, and a marker span reveals
* where the next character would be drawn.
*/
function caretCoordinates(textarea: HTMLTextAreaElement, position: number) {
const mirror = document.createElement("div");
const computed = getComputedStyle(textarea);
for (const prop of MIRROR_PROPS) mirror.style.setProperty(prop, computed.getPropertyValue(prop));
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.top = "0";
mirror.style.left = "-9999px";
mirror.style.whiteSpace = "pre-wrap";
mirror.style.overflowWrap = "break-word";
mirror.textContent = textarea.value.slice(0, position);
const marker = document.createElement("span");
marker.textContent = textarea.value.slice(position) || ".";
mirror.appendChild(marker);
document.body.appendChild(mirror);
const lineHeight = parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.4;
const coords = { top: marker.offsetTop, left: marker.offsetLeft, height: lineHeight };
mirror.remove();
return coords;
}
/** Finds an "@query" / "#query" being typed right before the caret. */
function findTrigger(text: string, caret: number, mentions: Mention[], allowTags: boolean): ActiveTrigger | null {
let i = caret - 1;
while (i >= 0 && caret - i <= 32 && QUERY_CHAR.test(text[i])) i--;
if (i < 0) return null;
const ch = text[i];
if (ch !== "@" && !(allowTags && ch === "#")) return null;
// The trigger must start a word…
if (i > 0 && !/[\s([{"'“]/.test(text[i - 1])) return null;
// …and must not belong to an existing mention token.
if (mentions.some((m) => i >= m.start && i < m.end)) return null;
return { trigger: ch as MentionTrigger, start: i, query: text.slice(i + 1, caret) };
}
/** Keeps mention ranges in sync with a text edit; mentions touched by the edit are dropped. */
function remapMentions(prev: string, next: string, mentions: Mention[]): Mention[] {
let start = 0;
const max = Math.min(prev.length, next.length);
while (start < max && prev[start] === next[start]) start++;
let suffix = 0;
while (suffix < max - start && prev[prev.length - 1 - suffix] === next[next.length - 1 - suffix]) suffix++;
const oldEnd = prev.length - suffix;
const delta = next.length - prev.length;
const out: Mention[] = [];
for (const m of mentions) {
if (m.end <= start) out.push(m);
else if (m.start >= oldEnd) out.push({ ...m, start: m.start + delta, end: m.end + delta });
// Otherwise the edit landed inside the token: it becomes plain text.
}
return out;
}
const initials = (label: string) =>
label
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join("");
/**
* A textarea with @mention (and optional #tag) autocomplete. The textarea's own text is
* transparent and a pixel-aligned layer behind it renders the same text with mentions
* highlighted, so native editing, selection, IME, spellcheck and undo all keep working.
* Suggestions open next to the caret (mirror-div coordinates), are navigated with
* aria-activedescendant, and a mention is removed as a whole token with Backspace.
*/
export function MentionInput({
value: valueProp,
defaultValue,
onChange,
users = [],
tags = [],
onSearch,
allowTags = true,
debounce = 150,
label = "Message",
hint,
placeholder = "Write a comment — type @ to mention someone",
rows = 4,
maxLength,
accent = "#FF7448",
disabled = false,
className = "",
style,
...props
}: MentionInputProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const fieldId = `mi-${uid}`;
const listboxId = `${fieldId}-listbox`;
const hintId = `${fieldId}-hint`;
const optionId = (i: number) => `${fieldId}-opt-${i}`;
const [inner, setInner] = React.useState<MentionValue>(defaultValue ?? EMPTY);
const value = valueProp ?? inner;
const [trig, setTrig] = React.useState<ActiveTrigger | null>(null);
const [dismissedAt, setDismissedAt] = React.useState<number | null>(null);
const [anchor, setAnchor] = React.useState({ left: 0, top: 0 });
const [activeIndex, setActiveIndex] = React.useState(0);
const [results, setResults] = React.useState<Results | null>(null);
const [announcement, setAnnouncement] = React.useState("");
const wrapRef = React.useRef<HTMLDivElement>(null);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const overlayRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const pendingCaret = React.useRef<number | null>(null);
const open = trig !== null && trig.start !== dismissedAt && !disabled;
const key = trig ? `${trig.trigger}${trig.query}` : "";
const isAsync = typeof onSearch === "function";
/* ------------------------------- suggestions ------------------------------- */
const runSearch = React.useEffectEvent((query: string, trigger: MentionTrigger, signal: AbortSignal) => onSearch?.(query, trigger, signal));
const trigger = trig?.trigger;
const query = trig?.query;
React.useEffect(() => {
if (!isAsync || !open || trigger === undefined || query === undefined) return;
const controller = new AbortController();
const requestKey = `${trigger}${query}`;
const timer = setTimeout(() => {
runSearch(query, trigger, controller.signal)?.then(
(items) => !controller.signal.aborted && setResults({ key: requestKey, items, error: false }),
() => !controller.signal.aborted && setResults({ key: requestKey, items: [], error: true }),
);
}, debounce);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [isAsync, open, trigger, query, debounce]);
const localItems = (): MentionItem[] => {
if (!trig) return [];
const q = trig.query.toLowerCase();
const source = trig.trigger === "@" ? users : tags;
return source
.filter((item) => !q || item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q) || item.description?.toLowerCase().includes(q))
.slice(0, 8);
};
const loading = isAsync && open && results?.key !== key;
// While a new query loads, keep showing the previous results for the same trigger (dimmed).
const items = !open ? [] : isAsync ? (results && results.key[0] === trigger ? results.items : []) : localItems();
const failed = isAsync && !loading && results?.error === true;
const active = items.length ? Math.min(activeIndex, items.length - 1) : -1;
/* --------------------------------- helpers -------------------------------- */
const commit = (next: MentionValue) => {
if (valueProp === undefined) setInner(next);
onChange?.(next);
};
/** Re-reads the caret and decides whether a trigger is active (and where its popup goes). */
const syncTrigger = (text: string, mentions: Mention[]) => {
const el = textareaRef.current;
if (!el) return;
const caret = el.selectionStart === el.selectionEnd ? el.selectionStart : -1;
const next = caret >= 0 ? findTrigger(text, caret, mentions, allowTags) : null;
if (!next) {
setTrig(null);
setDismissedAt(null);
return;
}
if (!trig || trig.start !== next.start || trig.trigger !== next.trigger) {
setActiveIndex(0);
placePopup(next.start);
}
if (trig?.query !== next.query) setActiveIndex(0);
setTrig(next);
};
const placePopup = (index: number) => {
const el = textareaRef.current;
const wrap = wrapRef.current;
if (!el || !wrap) return;
const c = caretCoordinates(el, index);
const left = el.offsetLeft + c.left - el.scrollLeft;
const top = el.offsetTop + c.top - el.scrollTop + c.height + 6;
const maxLeft = Math.max(0, wrap.clientWidth - POPUP_WIDTH);
setAnchor({ left: Math.round(Math.min(Math.max(0, left - 8), maxLeft)), top: Math.round(top) });
};
const insert = (item: MentionItem) => {
const el = textareaRef.current;
if (!trig || !el) return;
const caret = el.selectionStart;
const token = `${trig.trigger}${item.label}`;
const before = value.text.slice(0, trig.start);
const after = value.text.slice(caret);
const spacer = after.startsWith(" ") ? "" : " ";
const text = before + token + spacer + after;
const delta = text.length - value.text.length;
const mentions = [
...value.mentions.filter((m) => m.end <= trig.start),
{ id: item.id, label: item.label, trigger: trig.trigger, start: trig.start, end: trig.start + token.length },
...value.mentions.filter((m) => m.start >= caret).map((m) => ({ ...m, start: m.start + delta, end: m.end + delta })),
];
if (maxLength && text.length > maxLength) {
setAnnouncement("Not enough room for this mention.");
return;
}
commit({ text, mentions });
pendingCaret.current = trig.start + token.length + 1;
setTrig(null);
setAnnouncement(`${trig.trigger === "@" ? "Mentioned" : "Tagged"} ${item.label}.`);
};
const removeMention = (m: Mention) => {
const text = value.text.slice(0, m.start) + value.text.slice(m.end);
const len = m.end - m.start;
const mentions = value.mentions.filter((x) => x !== m).map((x) => (x.start >= m.end ? { ...x, start: x.start - len, end: x.end - len } : x));
commit({ text, mentions });
pendingCaret.current = m.start;
setTrig(null);
setAnnouncement(`Removed ${m.trigger === "@" ? "mention" : "tag"} ${m.label}.`);
};
// Restore the caret after programmatic edits (insert / remove).
React.useLayoutEffect(() => {
const pos = pendingCaret.current;
const el = textareaRef.current;
if (pos === null || !el) return;
pendingCaret.current = null;
el.focus();
el.setSelectionRange(pos, pos);
if (overlayRef.current) overlayRef.current.scrollTop = el.scrollTop;
});
// Keep the highlighted option visible inside the list (never scroll the page).
React.useEffect(() => {
const list = listRef.current;
const el = active >= 0 ? list?.querySelector<HTMLElement>(`#${CSS.escape(optionId(active))}`) : null;
if (!list || !el) return;
if (el.offsetTop < list.scrollTop) list.scrollTop = el.offsetTop - 4;
else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight)
list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight + 4;
});
/* --------------------------------- handlers -------------------------------- */
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = event.currentTarget.value;
const mentions = remapMentions(value.text, text, value.mentions);
commit({ text, mentions });
setAnnouncement("");
syncTrigger(text, mentions);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.nativeEvent.isComposing) return;
if (open && (items.length > 0 || event.key === "Escape")) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setActiveIndex((active + 1) % items.length);
return;
case "ArrowUp":
event.preventDefault();
setActiveIndex((active - 1 + items.length) % items.length);
return;
case "Enter":
case "Tab":
if (active >= 0) {
event.preventDefault();
insert(items[active]);
}
return;
case "Escape":
event.preventDefault();
if (trig) setDismissedAt(trig.start);
return;
}
}
// Backspace / Delete next to a mention removes the whole token.
const el = event.currentTarget;
if (el.selectionStart !== el.selectionEnd) return;
const caret = el.selectionStart;
if (event.key === "Backspace") {
const m = value.mentions.find((x) => caret > x.start && caret <= x.end);
if (m) {
event.preventDefault();
removeMention(m);
}
} else if (event.key === "Delete") {
const m = value.mentions.find((x) => caret >= x.start && caret < x.end);
if (m) {
event.preventDefault();
removeMention(m);
}
}
};
/* ---------------------------------- render --------------------------------- */
const sorted = [...value.mentions].sort((a, b) => a.start - b.start);
const segments: React.ReactNode[] = [];
let cursor = 0;
sorted.forEach((m, i) => {
if (m.start > cursor) segments.push(value.text.slice(cursor, m.start));
segments.push(
<mark
key={`${m.start}-${i}`}
className="rounded-[4px] bg-[color-mix(in_srgb,var(--mi-accent)_18%,transparent)] text-[color-mix(in_srgb,var(--mi-accent)_62%,#000)] shadow-[0_0_0_1.5px_color-mix(in_srgb,var(--mi-accent)_18%,transparent)] dark:bg-[color-mix(in_srgb,var(--mi-accent)_22%,transparent)] dark:text-[color-mix(in_srgb,var(--mi-accent)_70%,#fff)] dark:shadow-[0_0_0_1.5px_color-mix(in_srgb,var(--mi-accent)_22%,transparent)]"
>
{value.text.slice(m.start, m.end)}
</mark>,
);
cursor = m.end;
});
// A trailing newline needs a character after it to occupy a line in the overlay.
segments.push(value.text.slice(cursor) + (value.text.endsWith("\n") ? " " : ""));
// Shared box metrics: the overlay and the textarea must wrap identically.
const metrics =
"w-full px-3.5 py-3 text-[15px] leading-[1.6] tracking-normal whitespace-pre-wrap break-words [overflow-wrap:break-word] [scrollbar-gutter:stable] border border-transparent";
const describedBy = [hint ? hintId : null, props["aria-describedby"]].filter(Boolean).join(" ") || undefined;
const statusText = !open
? ""
: loading
? "Searching…"
: failed
? "Suggestions could not be loaded."
: items.length
? `${items.length} ${trig?.trigger === "#" ? (items.length === 1 ? "tag" : "tags") : items.length === 1 ? "person" : "people"} found. Use the arrow keys and Enter to insert.`
: "No matches.";
const count = value.mentions.length;
const showPopup = open && (items.length > 0 || loading || failed || (trig?.query ?? "") !== "");
return (
<div
{...props}
aria-describedby={undefined}
className={`w-full ${className}`}
style={{ ["--mi-accent" as string]: accent, ...style }}
>
<label htmlFor={fieldId} className="mb-2 block font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</label>
<div
ref={wrapRef}
className={`relative rounded-xl border bg-white transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none dark:bg-zinc-950 ${
disabled ? "opacity-50" : ""
} border-zinc-900/15 focus-within:border-[var(--mi-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--mi-accent)_22%,transparent)] hover:border-zinc-900/30 dark:border-white/15 dark:hover:border-white/30`}
>
<div className="relative">
{/* Visible text with highlighted mentions, pixel-aligned behind the transparent textarea. */}
<div
ref={overlayRef}
aria-hidden="true"
className={`${metrics} pointer-events-none absolute inset-0 overflow-hidden text-zinc-900 dark:text-zinc-100`}
>
{segments}
</div>
<textarea
ref={textareaRef}
id={fieldId}
rows={rows}
value={value.text}
placeholder={placeholder}
maxLength={maxLength}
disabled={disabled}
spellCheck
aria-autocomplete="list"
aria-controls={showPopup ? listboxId : undefined}
aria-activedescendant={showPopup && active >= 0 ? optionId(active) : undefined}
aria-describedby={describedBy}
onChange={handleChange}
onKeyDown={handleKeyDown}
onSelect={(event) => {
// Caret moves (clicks, arrow keys) can enter or leave a trigger; edits are handled in onChange.
if (event.currentTarget.value === value.text) syncTrigger(value.text, value.mentions);
}}
onScroll={(event) => {
if (overlayRef.current) overlayRef.current.scrollTop = event.currentTarget.scrollTop;
if (trig) placePopup(trig.start);
}}
onBlur={() => setTrig(null)}
className={`${metrics} relative block resize-none overflow-y-auto bg-transparent text-transparent caret-zinc-900 outline-hidden placeholder:text-zinc-400 selection:bg-[color-mix(in_srgb,var(--mi-accent)_28%,transparent)] disabled:cursor-not-allowed dark:caret-zinc-100 dark:placeholder:text-zinc-500`}
/>
</div>
<div className="flex items-center justify-between gap-3 border-t border-zinc-900/[0.06] px-3.5 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:border-white/[0.06] dark:text-zinc-500">
<span aria-hidden="true">
@ people{allowTags ? " · # tags" : ""}
</span>
<span className="tabular-nums">
{count} {count === 1 ? "mention" : "mentions"}
{maxLength ? ` · ${value.text.length}/${maxLength}` : ""}
</span>
</div>
<AnimatePresence>
{showPopup && (
<motion.div
key="popup"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: { duration: 0.1 } }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 40, mass: 0.5 }}
style={{ left: anchor.left, top: anchor.top, width: POPUP_WIDTH, transformOrigin: "top left" }}
className="absolute z-30 max-w-full overflow-hidden rounded-xl border border-zinc-900/10 bg-white shadow-[0_16px_40px_-16px_rgba(0,0,0,0.3)] dark:border-white/10 dark:bg-zinc-900"
onMouseDown={(e) => e.preventDefault()}
>
<div className="flex items-center justify-between border-b border-zinc-900/[0.06] px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:border-white/[0.06] dark:text-zinc-500">
<span>{trig?.trigger === "#" ? "Tags" : "People"}</span>
{loading && (
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-3 animate-spin text-[var(--mi-accent)] motion-reduce:animate-none">
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.8" />
<path d="M14 8a6 6 0 0 0-6-6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
)}
</div>
<div
ref={listRef}
id={listboxId}
role="listbox"
aria-label={trig?.trigger === "#" ? "Tag suggestions" : "People suggestions"}
aria-busy={loading || undefined}
className={`max-h-56 overflow-y-auto overscroll-contain p-1 transition-opacity ${loading && items.length ? "opacity-60" : ""}`}
>
{items.map((item, i) => (
<div
key={item.id}
id={optionId(i)}
role="option"
aria-selected={i === active}
onClick={() => insert(item)}
onPointerMove={(e) => {
if (e.pointerType === "mouse" && i !== active) setActiveIndex(i);
}}
className={`flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-[13px] ${
i === active
? "bg-[color-mix(in_srgb,var(--mi-accent)_12%,transparent)] text-zinc-950 dark:text-white"
: "text-zinc-700 dark:text-zinc-300"
}`}
>
<span
aria-hidden="true"
className={`grid size-6 shrink-0 place-items-center rounded-full text-[10px] font-semibold ${
trig?.trigger === "#"
? "rounded-md bg-zinc-900/[0.06] text-zinc-500 dark:bg-white/[0.08] dark:text-zinc-400"
: "bg-[color-mix(in_srgb,var(--mi-accent)_22%,transparent)] text-zinc-800 dark:text-zinc-100"
}`}
>
{trig?.trigger === "#" ? "#" : initials(item.label)}
</span>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.description && (
<span className="shrink-0 truncate font-mono text-[10px] tracking-[0.04em] text-zinc-400 dark:text-zinc-500">{item.description}</span>
)}
</div>
))}
</div>
{items.length === 0 && (
<p className="px-3 pb-2.5 pt-1 text-[13px] text-zinc-500 dark:text-zinc-400">
{loading ? "Searching…" : failed ? "Couldn’t load suggestions." : "No matches"}
</p>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{hint && (
<p id={hintId} className="mt-1.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400">
{hint}
</p>
)}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{announcement || statusText}
</span>
</div>
);
}