registry:block
Selection Actions
A floating AI toolbar attached beneath selected text.
Rendered example
The migration runs nightly at 2am UTC. If it fails twice in a row, page the on-call engineer and roll back automatically.
Install
lucide-reactorion-themeshimmer
Open registry JSON registry/blocks/selection-actions.tsx
"use client"
import { Check, ChevronRight, MessageCircleQuestion, RefreshCw, Scissors, Smile, Sparkles, Type, X } from "lucide-react"
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { Shimmer } from "@/registry/ui/shimmer"
/**
* SELECTION ACTIONS
* A contextual AI bar attached beneath selected text, with a compact
* action row that expands, and a thinking -> streaming -> result flow.
*/
const LEAD = "The migration runs nightly at 2am UTC. "
const PICKED = "If it fails twice in a row, page the on-call engineer and roll back automatically."
const REWRITE = "If it fails twice in a row, automatically roll back and page the on-call engineer."
type Mode = "idle" | "thinking" | "streaming" | "result"
function ShimmerLabel({ children }: { children: React.ReactNode }) {
return <Shimmer>{children}</Shimmer>
}
function StreamedText({ text, onDone }: { text: string; onDone: () => void }) {
const words = useMemo(() => text.split(" "), [text])
const [count, setCount] = useState(0)
useEffect(() => {
if (count >= words.length) {
onDone()
return
}
const t = setTimeout(() => setCount((c) => c + 1), 55)
return () => clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, words.length, onDone])
return <>{words.slice(0, count).join(" ")}</>
}
const control =
"inline-flex h-7 shrink-0 items-center gap-1 rounded-full px-2.5 text-[12px] font-normal text-ink transition-[background-color,color,transform] duration-150 hover:bg-hover active:scale-[0.96]"
const primary =
"inline-flex h-7 shrink-0 items-center gap-1 rounded-full bg-ink px-2.5 text-[12.5px] font-normal text-canvas shadow-hairline transition-[opacity,transform] duration-150 hover:opacity-90 active:scale-[0.96]"
export function SelectionActions() {
const [mode, setMode] = useState<Mode>("idle")
const [action, setAction] = useState("Improve")
const [expanded, setExpanded] = useState(false)
const [anchor, setAnchor] = useState({ x: 0, y: 0 })
const [positioned, setPositioned] = useState(false)
const hostRef = useRef<HTMLDivElement>(null)
const selectionRef = useRef<HTMLSpanElement>(null)
const frameRef = useRef<number | null>(null)
useEffect(() => {
if (mode !== "thinking") return
const t = setTimeout(() => setMode("streaming"), 700)
return () => clearTimeout(t)
}, [mode])
/* Position the bar beneath the last line of the selection span */
const place = useCallback(() => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
frameRef.current = requestAnimationFrame(() => {
const host = hostRef.current
const selection = selectionRef.current
if (!(host && selection)) return
const lines = Array.from(selection.getClientRects())
const lastLine = lines.at(-1)
if (!lastLine) return
const hostBounds = host.getBoundingClientRect()
setAnchor({
x: Math.round(lastLine.left - hostBounds.left + lastLine.width / 2),
y: Math.round(lastLine.bottom - hostBounds.top + 8),
})
setPositioned(true)
})
}, [])
useLayoutEffect(() => {
place()
}, [place])
useEffect(() => {
const host = hostRef.current
if (!host) return
const observer = new ResizeObserver(place)
observer.observe(host)
window.addEventListener("resize", place)
return () => {
observer.disconnect()
window.removeEventListener("resize", place)
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
}
}, [place])
const run = (nextAction: string) => {
setAction(nextAction)
setExpanded(false)
setMode("thinking")
}
const reset = () => {
setExpanded(false)
setAction("Improve")
setMode("idle")
}
const busy = mode === "thinking" || mode === "streaming"
const visible = positioned
const busyLabel =
action === "Improve"
? "Improving"
: action === "Shorten"
? "Shortening"
: action === "Change tone"
? "Changing tone"
: "Editing"
return (
<div className="w-full max-w-[460px]">
<div ref={hostRef} className="relative select-none pb-8">
<p className="text-[13px] leading-relaxed text-ink">
{LEAD}
<span
ref={selectionRef}
className="box-decoration-clone rounded-[3px] bg-[color-mix(in_srgb,var(--accent)_14%,var(--surface))] text-ink dark:bg-accent-tint"
>
{mode === "idle" || mode === "thinking" ? (
PICKED
) : mode === "streaming" ? (
<StreamedText text={REWRITE} onDone={() => setMode("result")} />
) : (
REWRITE
)}
</span>
</p>
<div
className="absolute top-0 left-0 z-10"
style={{
transform: `translate3d(${anchor.x}px, ${anchor.y}px, 0) translateX(-50%)`,
transition: "transform 320ms cubic-bezier(0.77,0,0.175,1), opacity 180ms ease-out",
opacity: visible ? 1 : 0,
pointerEvents: visible ? "auto" : "none",
}}
>
<div
className="flex h-9 w-fit max-w-[calc(100vw-48px)] items-center justify-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 font-sans text-ink shadow-overlay"
style={visible ? { animation: "pop-in 220ms cubic-bezier(0.23,1,0.32,1) both" } : {}}
>
{busy && (
<span className="inline-flex h-7 items-center gap-1.5 whitespace-nowrap px-2.5 text-[12.5px] font-normal text-ink-2">
<span
className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2"
style={{ animation: "spin 700ms linear infinite" }}
/>
{mode === "thinking" ? <ShimmerLabel>{busyLabel}…</ShimmerLabel> : <span>{busyLabel}…</span>}
</span>
)}
{mode === "result" && (
<>
<button type="button" onClick={reset} className={primary}>
<Check width={14} height={14} strokeWidth={1.8} aria-hidden />
Keep
</button>
<button type="button" onClick={reset} className={control}>
<X width={14} height={14} strokeWidth={1.8} aria-hidden />
Discard
</button>
<span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
<button
type="button"
aria-label="Try again"
onClick={() => run(action)}
className="flex size-7 shrink-0 items-center justify-center rounded-full text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink-2 active:scale-[0.96]"
>
<RefreshCw width={14} height={14} strokeWidth={1.8} aria-hidden />
</button>
</>
)}
{mode === "idle" && (
<div
className="flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width] duration-400"
style={{ maxWidth: expanded ? 462 : 224, transitionTimingFunction: "cubic-bezier(0.23,1,0.32,1)" }}
>
<button type="button" className={control}>
<MessageCircleQuestion width={14} height={14} strokeWidth={1.8} aria-hidden />
Explain
</button>
<button type="button" onClick={() => run("Improve")} className={control}>
<Sparkles width={14} height={14} strokeWidth={1.8} aria-hidden />
Improve
</button>
<div
className="flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity] duration-400"
style={{
maxWidth: expanded ? 262 : 0,
opacity: expanded ? 1 : 0,
transitionTimingFunction: "cubic-bezier(0.23,1,0.32,1)",
}}
>
<button type="button" onClick={() => run("Shorten")} className={control}>
<Scissors width={14} height={14} strokeWidth={1.8} aria-hidden />
Shorten
</button>
<button type="button" onClick={() => run("Change tone")} className={control}>
<Smile width={14} height={14} strokeWidth={1.8} aria-hidden />
Tone
</button>
<button type="button" onClick={() => run("Fix grammar")} className={control}>
<Type width={14} height={14} strokeWidth={1.8} aria-hidden />
Grammar
</button>
</div>
<span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
<button
type="button"
aria-label={expanded ? "Show fewer actions" : "Show more actions"}
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
className="flex size-7 shrink-0 items-center justify-center rounded-full text-ink transition-[background-color,transform] duration-200 hover:bg-hover active:scale-[0.96]"
>
<span
className="flex transition-transform duration-400"
style={{
transform: expanded ? "rotate(90deg)" : "rotate(0deg)",
transitionTimingFunction: "cubic-bezier(0.23,1,0.32,1)",
}}
>
<ChevronRight width={14} height={14} strokeWidth={1.8} aria-hidden />
</span>
</button>
</div>
)}
</div>
</div>
</div>
</div>
)
}