{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "selection-toolbar",
  "title": "Selection Toolbar",
  "description": "A small toolbar that appears over text the moment it is selected, carrying your own actions: quote, copy, highlight, share, comment, define, translate, or \"ask about this\". Reach for it wherever the next thing a reader wants to do is about the words they just dragged across — a long-form article or documentation site, a proofreading or editorial review screen, an AI chat that quotes a passage back as the question, a knowledge base with highlights, a transcript or subtitle editor, a code or log viewer, a comments-on-text workflow. Three things it gets right that a hand-written version does not, each measured in a browser rather than assumed. First, it opens on selectionchange, not mouseup: a selection made with Shift and the arrow keys fires no mouseup at all (measured: five Shift+ArrowRight presses, zero mouseup events), so a mouseup toolbar is one keyboard users never see. It is not debounced either, because the browser already coalesces — three selection changes inside one task produced a single event, and a full click-drag produced three, so a debounce only makes the toolbar trail the highlight. Second, it is anchored to a line of the selection rather than to its bounding box: a selection spanning two lines returns one rect per line, and the union of those is the width of the column, so centring on it puts the toolbar over text the reader can see is not selected (measured: a selection whose visible first line started at x=101.8 had a union starting at x=29). Third, it keeps up with a selection inside a scrollable pane, because its scroll listener is in the capture phase — a nested scroller's scroll event does not bubble, and of a window-bubble, window-capture and document-bubble listener, only the capture one heard an inner pane scroll by 30px. It also refuses to open for text selected inside an <input> or <textarea>. That selection fires selectionchange and makes the document selection report the field's own text, while being collapsed and having no client rects — the trap that makes a naive toolbar flash over every search box with nowhere to point. Mousedown is prevented on the toolbar so the caret stays where it was: measured, pressing a button never destroyed the selection, but without the guard focus moved out of a contenteditable and onto the button. The selected text is captured when the toolbar opens rather than read in the click handler, because focusing an input wipes the document selection outright — so an action that opens a comment box would otherwise lose the thing it was about. Accessible by construction: a role=\"toolbar\" with one tab stop and arrow-key movement between the actions, Escape to dismiss, focus-visible rings, labels for icon-only buttons, and shadcn tokens throughout so it follows the theme in light and dark. Optionally scoped to one element, so \"quote this\" is not offered for text selected in the site navigation. Common asks it answers: \"selection toolbar react\", \"floating toolbar on text selection\", \"medium style highlight menu\", \"text selection popup react\", \"show a button when text is selected\", \"bubble menu without tiptap\", \"selection toolbar shadcn\", \"highlight text and show options\", \"quote selected text\", \"ask ai about the selected text\", \"react hook for window.getSelection\", \"useSelection hook\", \"position a popup over a text selection\", \"getClientRects multi-line selection\", \"selectionchange in react\", \"selection toolbar disappears when i click it\", \"keep focus in contenteditable when clicking a toolbar button\", \"text selection toolbar keyboard accessible\", \"shift arrow selection fires no mouseup\", \"selection toolbar inside a scrollable div\", \"toolbar drifts when the page scrolls\".",
  "files": [
    {
      "path": "registry/ui/selection-toolbar.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * A rectangle in viewport coordinates, as `getClientRects` gives them.\n *\n * Structural rather than `DOMRect` so the placement below can be exercised with plain numbers.\n */\nexport interface SelectionRect {\n  top: number\n  left: number\n  bottom: number\n  right: number\n  width: number\n  height: number\n}\n\n/** Which side of the selected text the toolbar ended up on. Mirrored to `data-side`. */\nexport type ToolbarSide = \"above\" | \"below\"\n\nexport interface Placement {\n  /** Viewport coordinates, for `position: fixed`. */\n  left: number\n  top: number\n  side: ToolbarSide\n}\n\n/**\n * Whether a selection is one this toolbar can point at.\n *\n * Both halves carry weight, and the second is the one that is easy to leave out. Text selected\n * inside an `<input>` or `<textarea>` fires `selectionchange` on the document like any other\n * selection, **and the document's own selection reports that field's text** — measured in\n * Chromium 152: focusing an input and calling `setSelectionRange(0, 5)` left\n * `getSelection().toString()` reading `\"focus\"`, with `rangeCount` 1 and `anchorNode` the body.\n * A toolbar keyed on \"the selection is not empty\" therefore opens every time someone drags\n * across a search box.\n *\n * What that selection does not have is a shape. `isCollapsed` is true and `getClientRects()` is\n * empty, because the field's selection belongs to the field rather than to the document — so\n * there is nowhere to put a toolbar even if opening it were right. Those two are the test, and\n * the text never is.\n */\nexport function selectionIsPresentable(snapshot: {\n  collapsed: boolean\n  rectCount: number\n}): boolean {\n  return !snapshot.collapsed && snapshot.rectCount > 0\n}\n\nconst clamp = (value: number, low: number, high: number) =>\n  Math.min(Math.max(value, low), high)\n\nexport interface PlaceToolbarOptions {\n  /** One rect per line of the selection, in document order. See `placeToolbar`. */\n  rects: readonly SelectionRect[]\n  /** The toolbar's own measured size. Zero before it has been measured, which is a valid first pass. */\n  width: number\n  height: number\n  viewportWidth: number\n  viewportHeight: number\n  /** Distance between the toolbar and the line it points at. */\n  gap: number\n  /** Closest the toolbar may come to a viewport edge. */\n  margin: number\n}\n\n/**\n * Where to put the toolbar for a given selection.\n *\n * **It is anchored to a line, not to the selection's bounding box**, and that is the whole reason\n * this is a function rather than two lines at the call site. A selection spanning two lines\n * returns two rects — measured: selecting 60 characters of a 260px column gave rects at\n * `left: 29 → 284.9` and `left: 29 → 153` — and the union of those two is the width of the\n * *column*. Centring on the union therefore centres the toolbar on the paragraph rather than on\n * any text the user can see highlighted, and it is worst exactly when it is most visible: for a\n * backward selection starting mid-line, the union ran `29 → 284.9` while the only highlighted\n * text on that line started at `101.8`.\n *\n * The first line is the anchor, not the line the caret is on. Chromium does expose\n * `Selection.direction`, but it answered `\"none\"` for a real mouse drag in the same session where\n * it answered `\"forward\"` for a keyboard selection, so which end the caret sits at is not\n * dependable; and a toolbar that opens under the pointer that just released is a toolbar that\n * gets clicked by accident. Document order is both knowable and steady.\n *\n * Above is preferred because the text *below* a selection is what the reader is moving toward.\n * When there is no room above, the toolbar flips under the last line. When there is no room on\n * either side — a selection taller than the viewport — it is clamped inside the viewport and will\n * overlap the text, on the grounds that a toolbar covering a word is better than one off-screen.\n */\nexport function placeToolbar({\n  rects,\n  width,\n  height,\n  viewportWidth,\n  viewportHeight,\n  gap,\n  margin,\n}: PlaceToolbarOptions): Placement | null {\n  if (rects.length === 0) return null\n\n  const first = rects[0]\n  const last = rects[rects.length - 1]\n\n  const aboveTop = first.top - gap - height\n  const fitsAbove = aboveTop >= margin\n  const anchor = fitsAbove ? first : last\n  const side: ToolbarSide = fitsAbove ? \"above\" : \"below\"\n\n  // `Math.max(margin, ...)` keeps the clamp well-ordered when the toolbar is larger than the\n  // space it is being fitted into; without it the high bound falls below the low one and the\n  // result is the far edge rather than the near one.\n  const top = clamp(\n    fitsAbove ? aboveTop : last.bottom + gap,\n    margin,\n    Math.max(margin, viewportHeight - height - margin)\n  )\n  const left = clamp(\n    anchor.left + anchor.width / 2 - width / 2,\n    margin,\n    Math.max(margin, viewportWidth - width - margin)\n  )\n\n  return { left, top, side }\n}\n\n/**\n * Whether two placements say the same thing.\n *\n * Used to hold the previous object when nothing moved. A component that re-measures on every\n * commit and writes the result back to state re-renders forever unless the unchanged case returns\n * the identical object, because React bails out on identity and not on shape.\n */\nexport function samePlacement(a: Placement | null, b: Placement | null): boolean {\n  if (a === b) return true\n  if (a === null || b === null) return false\n  return a.left === b.left && a.top === b.top && a.side === b.side\n}\n\nexport interface SelectionToolbarOptions {\n  /**\n   * Limits the toolbar to selections inside this element. Omit it to serve the whole page.\n   *\n   * Point it at the article or editor the actions make sense for: \"quote this\" is wrong for text\n   * selected in the site navigation.\n   */\n  rootRef?: React.RefObject<HTMLElement | null>\n  /** Stop watching entirely. The toolbar renders nothing and no listeners are attached. */\n  disabled?: boolean\n  /** Distance between the toolbar and the line it points at (default 8). */\n  gap?: number\n  /** Closest the toolbar may come to a viewport edge (default 8). */\n  margin?: number\n}\n\nexport interface SelectionToolbarState {\n  open: boolean\n  /** The selected text as it was when the toolbar opened. See the note in `useSelectionToolbar`. */\n  text: string\n  /** A clone of the selected range, for an action that needs the nodes rather than the string. */\n  range: Range | null\n  /** Null for the one commit between opening and being measured. */\n  placement: Placement | null\n  close: () => void\n  /** Attach to the toolbar element; its size is half of the placement. */\n  toolbarRef: React.RefObject<HTMLDivElement | null>\n}\n\n/**\n * Watches the document selection and works out where a toolbar for it belongs.\n *\n * The event is `selectionchange` on the document, and it is the only source — not `mouseup`.\n * A selection made with Shift and the arrow keys produces **no mouseup at all** (measured: five\n * Shift+ArrowRight presses, zero mouseup events), so a toolbar built on mouseup is a toolbar\n * keyboard users never see.\n *\n * **There is no debounce, deliberately.** `selectionchange` has a reputation for firing in floods,\n * and it does not: the browser queues it rather than dispatching it synchronously, and coalesces\n * everything that happened in one task into a single event. Measured, in the same session — three\n * programmatic selection changes in one task produced **1** event; five Shift+ArrowRight presses\n * produced **2**; a full click-drag across two lines produced **3**. The handler is already\n * bounded by the frame rate, and a debounce on top of that only makes the toolbar lag the\n * highlight it is pointing at.\n *\n * Closing needs no listener of its own either: the mousedown that begins the next drag collapses\n * the selection, which is itself a `selectionchange` with nothing selected.\n */\nexport function useSelectionToolbar({\n  rootRef,\n  disabled = false,\n  gap = 8,\n  margin = 8,\n}: SelectionToolbarOptions = {}): SelectionToolbarState {\n  const [open, setOpen] = React.useState(false)\n  const [text, setText] = React.useState(\"\")\n  const [placement, setPlacement] = React.useState<Placement | null>(null)\n  // Held in a ref rather than state: it is read during measurement, and a new object on every\n  // `selectionchange` during a drag would re-render for a value nothing renders.\n  const rangeRef = React.useRef<Range | null>(null)\n  const toolbarRef = React.useRef<HTMLDivElement | null>(null)\n\n  const close = React.useCallback(() => {\n    rangeRef.current = null\n    setOpen(false)\n    setPlacement(null)\n  }, [])\n\n  const reposition = React.useCallback(() => {\n    const range = rangeRef.current\n    if (range === null) return\n\n    // Re-read rather than remembered. The rects are viewport coordinates, so they are stale the\n    // moment anything scrolls — and the range is the thing that knows where it is now.\n    const rects: SelectionRect[] =\n      typeof range.getClientRects === \"function\" ? Array.from(range.getClientRects()) : []\n    if (rects.length === 0) {\n      // The text the toolbar was pointing at has been removed from the page, or collapsed into a\n      // field. Either way there is nothing left to point at.\n      close()\n      return\n    }\n\n    const box = toolbarRef.current?.getBoundingClientRect?.()\n    const next = placeToolbar({\n      rects,\n      width: box?.width ?? 0,\n      height: box?.height ?? 0,\n      viewportWidth: window.innerWidth,\n      viewportHeight: window.innerHeight,\n      gap,\n      margin,\n    })\n    setPlacement((previous) => (samePlacement(previous, next) ? previous : next))\n  }, [close, gap, margin])\n\n  // --- watching the selection --------------------------------------------------------------\n  React.useEffect(() => {\n    if (disabled) return\n\n    const read = () => {\n      const selection = document.getSelection()\n      if (selection === null || selection.rangeCount === 0) {\n        close()\n        return\n      }\n      const range = selection.getRangeAt(0)\n      const root = rootRef?.current\n      if (root && !root.contains(range.commonAncestorContainer)) {\n        close()\n        return\n      }\n      if (\n        !selectionIsPresentable({\n          collapsed: selection.isCollapsed,\n          rectCount: range.getClientRects().length,\n        })\n      ) {\n        close()\n        return\n      }\n      // Cloned: the live range the selection hands back is mutated in place as the drag continues,\n      // so an action that ran later would act on wherever the selection ended rather than on what\n      // the toolbar was opened for.\n      rangeRef.current = range.cloneRange()\n      setText(selection.toString())\n      setOpen(true)\n    }\n\n    document.addEventListener(\"selectionchange\", read)\n    return () => document.removeEventListener(\"selectionchange\", read)\n  }, [close, disabled, rootRef])\n\n  // --- keeping it in place -------------------------------------------------------------------\n  // Runs after every commit while the toolbar is up, with no dependency list on purpose: the first\n  // commit is the one that creates the element whose width the placement is centred on, so the\n  // measurement has to happen after a render rather than before one. It terminates because\n  // `reposition` hands back the previous placement object when nothing moved.\n  React.useLayoutEffect(() => {\n    if (open) reposition()\n  })\n\n  React.useEffect(() => {\n    if (!open) return\n\n    const onViewportChange = () => reposition()\n    // Capture phase. A scroll event from a nested scroller does not bubble, so a listener on the\n    // window hears only the document scrolling — measured: scrolling an inner pane by 30px moved\n    // the selection rect by exactly 30px, and of three listeners (window bubble, window capture,\n    // document bubble) **only the capture one heard it**. A selection inside a scrollable pane is\n    // the ordinary case for a chat log or a diff view, not an exotic one.\n    window.addEventListener(\"scroll\", onViewportChange, true)\n    window.addEventListener(\"resize\", onViewportChange)\n    // Pinch-zoom moves the visual viewport under the layout viewport without a scroll or resize\n    // event, which is the mobile version of the toolbar drifting away from its text.\n    const visual = window.visualViewport\n    visual?.addEventListener(\"resize\", onViewportChange)\n    visual?.addEventListener(\"scroll\", onViewportChange)\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") close()\n    }\n    document.addEventListener(\"keydown\", onKeyDown)\n\n    return () => {\n      window.removeEventListener(\"scroll\", onViewportChange, true)\n      window.removeEventListener(\"resize\", onViewportChange)\n      visual?.removeEventListener(\"resize\", onViewportChange)\n      visual?.removeEventListener(\"scroll\", onViewportChange)\n      document.removeEventListener(\"keydown\", onKeyDown)\n    }\n  }, [close, open, reposition])\n\n  return { open, text, range: rangeRef.current, placement, close, toolbarRef }\n}\n\nexport interface SelectionAction {\n  /** Stable identity for the list. */\n  id: string\n  /** The button's accessible name. Shown as text unless `icon` is given and `iconOnly` is set. */\n  label: string\n  icon?: React.ReactNode\n  /** Render the icon alone, with `label` kept as the accessible name. */\n  iconOnly?: boolean\n  /**\n   * Receives what was selected when the toolbar opened, not what is selected now. See the note on\n   * `SelectionToolbar` for why those differ.\n   */\n  onSelect: (selection: { text: string; range: Range | null }) => void\n  /** Leave the toolbar up after this action — for a toggle, say. Default false. */\n  keepOpen?: boolean\n}\n\nexport interface SelectionToolbarProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\">,\n    SelectionToolbarOptions {\n  actions: readonly SelectionAction[]\n  /** Accessible name for the toolbar itself. */\n  label?: string\n}\n\n/**\n * A small toolbar that appears over text the moment it is selected — quote it, copy it, highlight\n * it, share it, ask about it.\n *\n * Render it **immediately after the content it serves**, because that is what puts it in the tab\n * order right after the text: a reader who selected with Shift and the arrow keys then reaches the\n * actions with one Tab. It is `position: fixed`, so where it sits in the markup costs nothing\n * visually and buys that.\n *\n * **Mousedown is prevented on the whole toolbar, and the reason is not the one usually given.**\n * Pressing a button does not destroy the selection — measured through mousedown, mouseup and click\n * on a plain button, inside a `contenteditable` and outside one, the selection was intact at every\n * step. What moves is *focus*: without the guard, `document.activeElement` went from the editor to\n * the button and the caret left the text; with `preventDefault` on mousedown it stayed on the\n * editor, and the click still fired. So the guard earns its place in an editor, where the person\n * is going to keep typing, and it is harmless on an article where nobody was focused anyway.\n *\n * **The text is captured when the toolbar opens**, not read when a button is pressed, and that is\n * not an optimisation. Focusing an `<input>` wipes the document selection outright — measured: a\n * selection reading \"Alpha bravo charlie\" became `\"\"` and collapsed the instant an input took\n * focus, while focusing a `<button>` left it untouched. An action that opens a comment box or a\n * share dialog with a field in it has therefore destroyed the thing it was about before its own\n * handler can read it. The buttons here are buttons for the same reason.\n *\n * What is *not* covered: touch. On a phone the OS draws its own selection handles and a context\n * menu over the same text, and this toolbar will sit among them. That was not measurable on a\n * desktop browser and so is not claimed — if you ship this to a touch layout, look at it on a real\n * device before deciding the gap is wide enough.\n */\nexport function SelectionToolbar({\n  actions,\n  rootRef,\n  disabled,\n  gap,\n  margin,\n  label = \"Selection actions\",\n  className,\n  style,\n  onMouseDown: onMouseDownProp,\n  onKeyDown: onKeyDownProp,\n  ...props\n}: SelectionToolbarProps) {\n  const { open, text, range, placement, close, toolbarRef } = useSelectionToolbar({\n    rootRef,\n    disabled,\n    gap,\n    margin,\n  })\n\n  // One tab stop for the whole toolbar, arrows between the buttons: the toolbar pattern, and the\n  // reason it exists is that a five-button toolbar appearing mid-article would otherwise add five\n  // tab stops to every paragraph.\n  const [activeIndex, setActiveIndex] = React.useState(0)\n  const buttonsRef = React.useRef<(HTMLButtonElement | null)[]>([])\n\n  // Adjusted during render rather than in an effect. An effect would reset the roving tab stop one\n  // commit *after* the toolbar reopened, so the first arrow key of a new selection would move from\n  // whichever button the last selection left behind; and a toolbar whose keyboard position depends\n  // on a dependency array is a toolbar that breaks the moment one is edited.\n  const [openedFor, setOpenedFor] = React.useState(open)\n  if (open !== openedFor) {\n    setOpenedFor(open)\n    if (open) setActiveIndex(0)\n  }\n\n  const focusAt = (index: number) => {\n    setActiveIndex(index)\n    buttonsRef.current[index]?.focus?.()\n  }\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDownProp?.(event)\n    if (event.defaultPrevented || actions.length === 0) return\n    const last = actions.length - 1\n    if (event.key === \"ArrowRight\") {\n      event.preventDefault()\n      focusAt(activeIndex === last ? 0 : activeIndex + 1)\n    } else if (event.key === \"ArrowLeft\") {\n      event.preventDefault()\n      focusAt(activeIndex === 0 ? last : activeIndex - 1)\n    } else if (event.key === \"Home\") {\n      event.preventDefault()\n      focusAt(0)\n    } else if (event.key === \"End\") {\n      event.preventDefault()\n      focusAt(last)\n    }\n  }\n\n  const onMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {\n    onMouseDownProp?.(event)\n    // See the note on this component. Bubbled from the buttons, and prevented here so every\n    // control inside gets the same treatment without each having to remember.\n    if (!event.defaultPrevented) event.preventDefault()\n  }\n\n  if (!open || actions.length === 0) return null\n\n  return (\n    <div\n      {...props}\n      ref={toolbarRef}\n      role=\"toolbar\"\n      aria-label={label}\n      aria-orientation=\"horizontal\"\n      data-side={placement?.side}\n      onMouseDown={onMouseDown}\n      onKeyDown={onKeyDown}\n      style={{\n        position: \"fixed\",\n        left: placement?.left ?? 0,\n        top: placement?.top ?? 0,\n        // Hidden rather than unrendered for the one commit before it has been measured: the\n        // measurement needs the element, and `visibility: hidden` keeps it out of both the\n        // accessibility tree and the tab order while it is still in the wrong place.\n        visibility: placement === null ? \"hidden\" : \"visible\",\n        ...style,\n      }}\n      className={cn(\n        \"z-50 flex items-center gap-0.5 rounded-md border bg-popover p-1 text-popover-foreground shadow-md\",\n        className\n      )}\n    >\n      {actions.map((action, index) => (\n        <button\n          key={action.id}\n          ref={(node) => {\n            buttonsRef.current[index] = node\n          }}\n          type=\"button\"\n          tabIndex={index === activeIndex ? 0 : -1}\n          aria-label={action.iconOnly ? action.label : undefined}\n          onClick={() => {\n            action.onSelect({ text, range })\n            if (!action.keepOpen) close()\n          }}\n          className=\"inline-flex h-8 items-center gap-1.5 rounded px-2 text-sm font-medium text-popover-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-popover\"\n        >\n          {action.icon ? (\n            <span aria-hidden=\"true\" className=\"inline-flex shrink-0\">\n              {action.icon}\n            </span>\n          ) : null}\n          {action.iconOnly ? null : action.label}\n        </button>\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
