Caret-Anchored Mention Popover
moumenlab/caret-mention-popoverFreeComponent
An @-mention popover anchored to the text caret via a hidden mirror, gliding as you type.
Live preview
live · rendered by the registry
Rendered by moumenlab on their own page — this is the component running, not a screenshot of it.Install it
npx shadcn@latest add https://lab.moumen.dev/r/caret-mention-popover.jsonSource
1"use client";23import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";4import { MotionConfig, motion } from "motion/react";56// Mention composer - a caret-anchored @mention popover.7//8// A textarea won't tell you where its caret is - the only API is selectionStart,9// a character index, no x/y. So most popovers anchor to the FIELD (bottom-left,10// like a dropdown), which feels detached from what you're typing. This one11// anchors to the CARET, with three stacked layers sharing one typography class12// so their metrics can never drift:13//14// · input - the real textarea (transparent background, visible text).15// · backdrop - behind it, same box, text painted TRANSPARENT; only the16// highlight boxes show through (inserted mentions as pills, the live @query17// as a lighter chip). The spans add zero advance (box-shadow fakes the18// inset), so the backdrop's glyph grid matches the textarea exactly.19// · mirror - invisible. value.slice(0, caret) is re-typeset into it plus a20// marker <span>; the marker's offsetLeft/offsetTop IS the caret's x/y.21//22// The popover is positioned with `translate` (left/top stay 0), so following the23// caret is one retargetable transition - it glides, and a fresh open snaps. It24// clamps horizontally and flips above when the viewport runs out. Focus never25// leaves the textarea: ↑↓/Enter/Tab drive the list via aria-activedescendant.26//27// Animation via motion/react (the pill fade + send-icon swap) plus CSS28// transitions; honours prefers-reduced-motion. Requires the lab-theme tokens.29// Fully Tailwind, no CSS files.3031const EASE = [0.22, 1, 0.36, 1] as const;32const EASE_ICON = [0.2, 0, 0, 1] as const;3334// THE contract: input, backdrop and mirror all take this, so their metrics35// (font, line height, wrap width, spacing) cannot drift apart.36const TEXT = "font-[inherit] text-[0.9375rem] leading-6 tracking-normal whitespace-pre-wrap [overflow-wrap:break-word]";3738export interface Person {39 name: string;40 handle: string;41}4243export interface MentionState {44 open: boolean;45 query: string;46 matches: number;47 caret: { x: number; y: number } | null;48 placement: string;49 anchor: string;50 mentions: number;51 lastMention: string | null;52}5354interface Match {55 person: Person;56 score?: number;57 nameRange: [number, number] | null;58 handleRange: [number, number] | null;59}6061// Word-prefix on the name beats handle-prefix beats substring.62// … truncated
What it pulls in
npm packages
Other registry items
Files it writes
More from moumenlab
All 14 items| Component | Registry | Kind | Access | Installs | Command |
|---|---|---|---|---|---|
| Command Palette with Argument Chipscommand-palette | moumenlab | Components | Free | 1 dep | |
| Drag-to-Reorder Listdrag-to-reorder-list | moumenlab | Components | Free | 1 dep | |
| File Upload Staging Areafile-upload-staging | moumenlab | Components | Free | 1 dep | |
| Hover-Expand Icon Striphover-expand-icon-strip | moumenlab | Components | Free | 1 dep | |
| Inertial Wheel Listinertial-wheel-list | moumenlab | Components | Free | 1 dep | |
| Morphing Checkout Flowmorphing-checkout | moumenlab | Components | Free | 1 dep | |
| OTP Segmented Inputotp-segmented-input | moumenlab | Components | Free | 1 dep | |
| Schedule Builderschedule-builder | moumenlab | Components | Free | 1 dep |
