Skip to content
AI Interfaces block

Selection Action Toolbar

Highlight a passage and hand it to the model. Positioned from the range rect relative to its container, raised by selectionchange so Shift-Arrow works, and every button prevents the mousedown that would collapse the selection.

251 lineslucide-reactAdded 13 Aug 2026
  • selection
  • toolbar
  • rewrite
  • inline ai
  • editor

What's included

  • components/selection-ai-toolbar.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add selection-ai-toolbar

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

The renewal conversation should start ninety days out, not thirty. By the time a customer is thirty days from renewal they have already formed a view, and the only lever left is discount. Teams that open the conversation at ninety days spend it on scope instead of price, and close at a materially higher rate — the difference in our own book was eleven points last year.

Select any text above to raise the toolbar. Shift-Arrow works too.

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/selection-ai-toolbar.tsx
/**
 * <SelectionAiToolbar> — highlight a passage, hand it to the model.
 *
 * The pattern that puts AI into an existing editor without a chat panel. It
 * is mostly a positioning problem, and the positioning is where every
 * implementation breaks:
 *
 *  - The toolbar is placed from `getBoundingClientRect()` on the selection
 *    range, converted into coordinates relative to the *container*, not the
 *    viewport. Absolute-positioning against the viewport looks right until
 *    the page scrolls one pixel.
 *  - It is clamped inside the container, so selecting a word at the left
 *    edge does not push the toolbar off-screen.
 *  - `onMouseDown` calls `preventDefault` on every button. Without it, the
 *    press collapses the selection before the click handler runs, and the
 *    toolbar acts on nothing — the single most common bug in this pattern.
 *  - Selection is read on `selectionchange`, filtered to ranges inside this
 *    block. A `mouseup` handler misses keyboard selection (Shift-Arrow)
 *    entirely, which is most of the users who need this to be reachable.
 *
 * Accessibility: the toolbar is a `role="toolbar"` with roving `tabIndex`,
 * so it is one tab stop and arrow keys move along it — the ARIA toolbar
 * pattern. Escape dismisses it and returns focus to the passage. A keyboard
 * user can therefore select text, Tab once, and act, which a hover-only
 * popover never allows.
 */

'use client'

import * as React from 'react'
import { Languages, Maximize2, Minimize2, MoreHorizontal, Sparkles, SpellCheck } from 'lucide-react'

export interface SelectionAction {
  id: string
  label: string
  icon: 'explain' | 'expand' | 'shorten' | 'tone' | 'grammar'
}

export interface SelectionAiToolbarProps {
  /** The document body. Selecting inside it raises the toolbar. */
  passage?: string
  actions?: SelectionAction[]
  /**
   * Phrase to pre-select on load, so the block demonstrates itself. Set to
   * an empty string for the real behaviour, where nothing shows until the
   * user selects something.
   */
  demoPhrase?: string
  className?: string
}

const ACTION_ICON = {
  explain: Sparkles,
  expand: Maximize2,
  shorten: Minimize2,
  tone: Languages,
  grammar: SpellCheck,
} as const

const DEFAULT_ACTIONS: SelectionAction[] = [
  { id: 'explain', label: 'Explain', icon: 'explain' },
  { id: 'improve', label: 'Improve', icon: 'expand' },
  { id: 'shorten', label: 'Shorten', icon: 'shorten' },
  { id: 'tone', label: 'Tone', icon: 'tone' },
  { id: 'grammar', label: 'Grammar', icon: 'grammar' },
]

const DEFAULT_PASSAGE =
  'The renewal conversation should start ninety days out, not thirty. By the time a customer is thirty days from renewal they have already formed a view, and the only lever left is discount. Teams that open the conversation at ninety days spend it on scope instead of price, and close at a materially higher rate — the difference in our own book was eleven points last year.'

/**
 * On the first line on purpose.
 *
 * The toolbar floats above its selection, which is correct — and means a
 * demo selection on line two covers line one, so the block previews as
 * though it were broken. Anchoring the default to the opening words puts
 * the toolbar in the container's top padding instead of over the prose.
 */
const DEMO_PHRASE = 'The renewal conversation'

export function SelectionAiToolbar({
  passage = DEFAULT_PASSAGE,
  actions = DEFAULT_ACTIONS,
  demoPhrase = DEMO_PHRASE,
  className = '',
}: SelectionAiToolbarProps) {
  const containerRef = React.useRef<HTMLDivElement>(null)
  const passageRef = React.useRef<HTMLParagraphElement>(null)

  const [selection, setSelection] = React.useState<string>(demoPhrase)
  const [position, setPosition] = React.useState<{ top: number; left: number } | null>(null)
  const [focused, setFocused] = React.useState(0)
  const [ran, setRan] = React.useState<string | null>(null)

  /** Place the toolbar over a rect, clamped inside the container. */
  const place = React.useCallback((rect: DOMRect) => {
    const box = containerRef.current?.getBoundingClientRect()
    if (!box) return
    const centre = rect.left + rect.width / 2 - box.left
    setPosition({
      top: rect.top - box.top,
      // Half the toolbar's own width is unknown before it renders, so the
      // clamp uses a fixed inset rather than a measured one — close enough
      // for a bar of this size, and it never leaves the container.
      left: Math.min(Math.max(centre, 130), box.width - 130),
    })
  }, [])

  // The demo selection, measured after layout so the rect is real.
  React.useEffect(() => {
    if (!demoPhrase) return
    const node = passageRef.current?.querySelector<HTMLElement>('[data-demo-span]')
    if (node) place(node.getBoundingClientRect())
  }, [demoPhrase, place])

  React.useEffect(() => {
    function onSelectionChange() {
      const sel = window.getSelection()
      const text = sel?.toString().trim() ?? ''

      // Only ranges that start inside this block. Without the containment
      // check, selecting text anywhere else on the page raises this
      // toolbar — which is exactly what happens on a page of previews.
      const anchor = sel?.anchorNode
      const inside = anchor ? passageRef.current?.contains(anchor) : false

      if (!text || !inside || sel?.isCollapsed) return

      const rect = sel?.getRangeAt(0).getBoundingClientRect()
      if (!rect || rect.width === 0) return

      setSelection(text)
      setRan(null)
      setFocused(0)
      place(rect)
    }

    document.addEventListener('selectionchange', onSelectionChange)
    return () => document.removeEventListener('selectionchange', onSelectionChange)
  }, [place])

  /** Roving focus along the toolbar — the ARIA toolbar pattern. */
  function onKeyDown(event: React.KeyboardEvent) {
    if (event.key === 'Escape') {
      setPosition(null)
      passageRef.current?.focus()
      return
    }
    if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return

    event.preventDefault()
    const next =
      event.key === 'ArrowRight'
        ? (focused + 1) % actions.length
        : (focused - 1 + actions.length) % actions.length
    setFocused(next)
    containerRef.current
      ?.querySelectorAll<HTMLButtonElement>('[data-toolbar-item]')
      [next]?.focus()
  }

  const marked = demoPhrase ? passage.split(demoPhrase) : [passage]

  return (
    <div className={`mx-auto w-full max-w-2xl p-6 ${className}`}>
      {/* Extra head room so a toolbar raised from the first line lands in
          padding rather than on top of the text. */}
      <div
        ref={containerRef}
        className="relative rounded-2xl border border-border/60 bg-card p-6 pt-16"
      >
        <p
          ref={passageRef}
          tabIndex={-1}
          className="text-sm leading-loose outline-none"
        >
          {marked.length === 2 ? (
            <>
              {marked[0]}
              <span data-demo-span className="rounded bg-primary/20 px-0.5">
                {demoPhrase}
              </span>
              {marked[1]}
            </>
          ) : (
            passage
          )}
        </p>

        {/* -- Toolbar ---------------------------------------------------- */}
        {position && selection ? (
          <div
            role="toolbar"
            aria-label={`Actions for the selected text`}
            aria-orientation="horizontal"
            onKeyDown={onKeyDown}
            style={{ top: position.top, left: position.left }}
            className="absolute z-20 flex max-w-[calc(100%-1rem)] -translate-x-1/2 -translate-y-[calc(100%+8px)] flex-wrap items-center gap-0.5 rounded-xl border border-border/60 bg-card p-1 shadow-2xl"
          >
            {actions.map((action, i) => {
              const Icon = ACTION_ICON[action.icon]
              return (
                <button
                  key={action.id}
                  type="button"
                  data-toolbar-item
                  tabIndex={i === focused ? 0 : -1}
                  // Without this the press collapses the selection before
                  // the click lands, and the action runs on nothing.
                  onMouseDown={(e) => e.preventDefault()}
                  onClick={() => setRan(action.label)}
                  onFocus={() => setFocused(i)}
                  className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <Icon aria-hidden className="h-3.5 w-3.5" />
                  {action.label}
                </button>
              )
            })}

            <span aria-hidden className="mx-0.5 h-5 w-px bg-border" />

            <button
              type="button"
              data-toolbar-item
              tabIndex={-1}
              onMouseDown={(e) => e.preventDefault()}
              className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <MoreHorizontal aria-hidden className="h-3.5 w-3.5" />
              <span className="sr-only">More actions</span>
            </button>
          </div>
        ) : null}
      </div>

      {/* -- Result ------------------------------------------------------- */}
      <p role="status" className="mt-3 min-h-5 text-xs text-muted-foreground">
        {ran ? (
          <>
            <span className="font-medium text-foreground">{ran}</span> — sent “
            {selection.length > 60 ? `${selection.slice(0, 60)}…` : selection}” to the model.
          </>
        ) : (
          'Select any text above to raise the toolbar. Shift-Arrow works too.'
        )}
      </p>
    </div>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/selection-ai-toolbar.tsx and import it where you need the section:

import { SelectionAiToolbar } from '@/components/selection-ai-toolbar'

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
passageThe document body. Selecting inside it raises the toolbar.stringDEFAULT_PASSAGE
actionsSelectionAction[]DEFAULT_ACTIONS
demoPhrasePhrase to pre-select on load, so the block demonstrates itself. Set to an empty string for the real behaviour, where nothing shows until the user selects something.stringDEMO_PHRASE
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

selection-ai-toolbar.html
<!--
  Selection Action Toolbar — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<div class="mx-auto w-full max-w-2xl p-6 ">
  <div class="relative rounded-2xl border border-border/60 bg-card p-6 pt-16">
    <p tabindex="-1" class="text-sm leading-loose outline-none"><span data-demo-span="true" class="rounded bg-primary/20 px-0.5">The renewal conversation</span> should start ninety days out, not thirty. By the time a customer is thirty days from renewal they have already formed a view, and the only lever left is discount. Teams that open the conversation at ninety days spend it on scope instead of price, and close at a materially higher rate — the difference in our own book was eleven points last year.</p>
  </div>
  <p role="status" class="mt-3 min-h-5 text-xs text-muted-foreground">Select any text above to raise the toolbar. Shift-Arrow works too.</p>
</div>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

5 more blocks in Inline AI Actions

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Inline AI Actions

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Inline AI Actions blocks

View category
Open the full page for this block

Ghost Text Completion

Copilot-style completion in a plain textarea: a transparent-caret field over a metric-matched mirror, Tab to accept, Escape that actually sticks, and the suggestion announced rather than left as invisible grey text.

Inline AI Actions206 lines1 dep
Open the full page for this block

Agent-Adjusted Inspector

The model edits real properties and every change is attributed and reversible — per-property provenance in words, per-property revert, and sliders whose aria-valuetext carries the unit.

Inline AI Actions381 lines1 dep
Open the full page for this block

Unprompted Insight Cards

Findings the agent surfaced on its own, paged one at a time, each stating its magnitude rather than its direction, dismissible, and carrying a sparkline that is also a sentence for anyone who cannot see it.

Inline AI Actions284 lines1 dep
Open the full page for this block

Editor Slash Menu

The "/" menu inside a document: focus never leaves the editor, every row names what it acts on, and Escape cleans up the trigger text it inserted instead of leaving you to backspace it.

Inline AI Actions343 lines1 dep
Open the full page for this block

Inline AI Action Menu

The actions offered on a selection, each saying whether it rewrites in place or opens a draft beside it.

Inline AI Actions151 linesNo deps