# Floating Toolbar

> A selection-style floating toolbar with roving tabindex, separator and an overflow menu.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/floating-toolbar
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/floating-toolbar.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/floating-toolbar-demo.tsx"
"use client"

import * as React from "react"
import { Bold, Eraser, Copy, Italic, Link2, MessageSquare } from "lucide-react"

import {
  FloatingToolbar,
  getSelectionAnchor,
  selectTextRange,
  type ToolbarAnchor,
} from "@/components/floating-toolbar"
import { cn } from "@/lib/utils"

const TEXT =
  "Good typography is invisible. Readers notice the ideas, not the typeface, so pick one family, set generous line height, and let the whitespace do the heavy lifting. Select any phrase in this paragraph to format it."

type Kind = "bold" | "italic" | "link" | "comment"
interface Mark {
  start: number
  end: number
  kind: Kind
}
interface Selected {
  start: number
  end: number
  anchor: ToolbarAnchor
  containerWidth: number
}

function segments(marks: Mark[]) {
  const cuts = new Set([0, TEXT.length])
  for (const mark of marks) {
    cuts.add(mark.start)
    cuts.add(mark.end)
  }
  const points = [...cuts].sort((a, b) => a - b)
  return points.slice(0, -1).map((start, index) => {
    const end = points[index + 1]
    const kinds = marks.filter((m) => m.start <= start && m.end >= end).map((m) => m.kind)
    return { start, end, kinds }
  })
}

export default function FloatingToolbarDemo() {
  const [marks, setMarks] = React.useState<Mark[]>([])
  const [selected, setSelected] = React.useState<Selected | null>(null)
  const [status, setStatus] = React.useState("")
  const box = React.useRef<HTMLDivElement>(null)
  const text = React.useRef<HTMLParagraphElement>(null)

  const measure = React.useCallback(() => {
    if (!box.current || !text.current) return
    const next = getSelectionAnchor(text.current, box.current)
    setSelected(next)
  }, [])

  // Hide the toolbar as soon as the selection collapses (click elsewhere, arrow key, ...)
  React.useEffect(() => {
    const onChange = () => {
      const selection = window.getSelection()
      if (!selection || selection.isCollapsed) setSelected((prev) => (prev ? null : prev))
    }
    document.addEventListener("selectionchange", onChange)
    return () => document.removeEventListener("selectionchange", onChange)
  }, [])

  function covered(kind: Kind, start: number, end: number) {
    return marks.some((m) => m.kind === kind && m.start <= start && m.end >= end)
  }

  function apply(id: string) {
    if (!selected || !text.current) return
    const { start, end } = selected
    if (id === "bold" || id === "italic" || id === "link" || id === "comment") {
      const kind = id as Kind
      setMarks((list) =>
        covered(kind, start, end)
          ? list.filter((m) => !(m.kind === kind && m.start < end && m.end > start))
          : [...list, { start, end, kind }]
      )
      setStatus(`${covered(kind, start, end) ? "Removed" : "Applied"} ${kind}`)
    } else if (id === "clear") {
      setMarks((list) => list.filter((m) => !(m.start < end && m.end > start)))
      setStatus("Cleared formatting")
    } else if (id === "copy") {
      void navigator.clipboard?.writeText(TEXT.slice(start, end)).catch(() => undefined)
      setStatus("Copied selection")
    }
    // The paragraph re-renders its text nodes, so restore the selection and re-measure afterwards.
    requestAnimationFrame(() => {
      if (!text.current || !box.current) return
      selectTextRange(text.current, start, end)
      setSelected(getSelectionAnchor(text.current, box.current))
    })
  }

  const current = selected
  const isOn = (kind: Kind) => (current ? covered(kind, current.start, current.end) : false)

  return (
    <div ref={box} className="relative mx-auto w-full max-w-xl rounded-2xl border bg-card p-5 sm:p-8">
      <p className="mb-3 text-xs font-medium tracking-widest text-muted-foreground uppercase">Draft · Notes on type</p>
      <p
        ref={text}
        tabIndex={0}
        aria-label="Editable preview paragraph. Select text, then press Alt+F10 to reach the formatting toolbar."
        onMouseUp={() => requestAnimationFrame(measure)}
        onKeyUp={(event) => {
          if (event.key === "F10" && event.altKey && current) {
            box.current?.querySelector<HTMLElement>("[data-toolbar-item][tabindex='0']")?.focus()
            return
          }
          measure()
        }}
        className="rounded-lg text-lg leading-8 selection:bg-primary/25 focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"
      >
        {segments(marks).map(({ start, end, kinds }) => (
          <span
            key={`${start}-${end}-${kinds.join()}`}
            className={cn(
              kinds.includes("bold") && "font-bold",
              kinds.includes("italic") && "italic",
              kinds.includes("link") && "text-primary underline underline-offset-4",
              kinds.includes("comment") && "bg-amber-400/25"
            )}
          >
            {TEXT.slice(start, end)}
          </span>
        ))}
      </p>
      <p role="status" aria-live="polite" className="mt-4 min-h-5 text-xs text-muted-foreground">
        {status || "Tip: drag over some words, or use Shift+Arrow keys."}
      </p>

      <FloatingToolbar
        anchor={current?.anchor ?? null}
        containerWidth={current?.containerWidth ?? 0}
        label="Text formatting"
        groups={[
          [
            { id: "bold", label: "Bold", icon: <Bold />, pressed: isOn("bold") },
            { id: "italic", label: "Italic", icon: <Italic />, pressed: isOn("italic") },
          ],
          [
            { id: "link", label: "Link", icon: <Link2 />, pressed: isOn("link") },
            { id: "comment", label: "Comment", icon: <MessageSquare />, pressed: isOn("comment") },
          ],
        ]}
        overflow={[
          { id: "copy", label: "Copy text", icon: <Copy /> },
          { id: "clear", label: "Clear formatting", icon: <Eraser /> },
        ]}
        onAction={apply}
        onDismiss={() => {
          window.getSelection()?.removeAllRanges()
          setSelected(null)
          text.current?.focus()
        }}
      />
    </div>
  )
}
```

## Source

```tsx title="components/floating-toolbar.tsx"
"use client"

import * as React from "react"
import { Ellipsis } from "lucide-react"

import { cn } from "@/lib/utils"

export interface ToolbarAnchor {
  top: number
  left: number
  width: number
  height: number
}

export interface ToolbarAction {
  id: string
  label: string
  icon: React.ReactNode
  /** Set for toggle buttons; renders aria-pressed */
  pressed?: boolean
}

export interface ToolbarOverflowItem {
  id: string
  label: string
  icon?: React.ReactNode
}

export interface FloatingToolbarProps extends Omit<React.ComponentProps<"div">, "children" | "onSelect"> {
  /** Rectangle to hover over, relative to the positioned parent. null hides the toolbar. */
  anchor: ToolbarAnchor | null
  /** Width of the positioned parent, used to keep the toolbar inside it */
  containerWidth: number
  /** Groups of actions; a separator is drawn between groups */
  groups: ToolbarAction[][]
  overflow?: ToolbarOverflowItem[]
  onAction: (id: string) => void
  /** Called when Escape is pressed inside the toolbar */
  onDismiss?: () => void
  label?: string
}

const GAP = 8
const MARGIN = 8

/** Measures a DOM range or element against a positioned container. Call from event handlers only. */
export function getSelectionAnchor(textRoot: HTMLElement, container: HTMLElement) {
  const selection = window.getSelection()
  if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null
  const range = selection.getRangeAt(0)
  if (!textRoot.contains(range.commonAncestorContainer)) return null
  const pre = document.createRange()
  pre.selectNodeContents(textRoot)
  pre.setEnd(range.startContainer, range.startOffset)
  const start = pre.toString().length
  const end = start + range.toString().length
  if (end <= start) return null
  return { start, end, anchor: rectWithin(range.getBoundingClientRect(), container), containerWidth: container.clientWidth }
}

export function getElementAnchor(element: HTMLElement, container: HTMLElement) {
  return { anchor: rectWithin(element.getBoundingClientRect(), container), containerWidth: container.clientWidth }
}

/** Re-selects a character range of an element's text, e.g. after a re-render replaced the nodes. */
export function selectTextRange(textRoot: HTMLElement, start: number, end: number) {
  const walker = document.createTreeWalker(textRoot, NodeFilter.SHOW_TEXT)
  const range = document.createRange()
  let offset = 0
  let started = false
  for (let node = walker.nextNode(); node; node = walker.nextNode()) {
    const length = node.textContent?.length ?? 0
    if (!started && start <= offset + length) {
      range.setStart(node, start - offset)
      started = true
    }
    if (started && end <= offset + length) {
      range.setEnd(node, end - offset)
      break
    }
    offset += length
  }
  const selection = window.getSelection()
  selection?.removeAllRanges()
  selection?.addRange(range)
}

function rectWithin(rect: DOMRect, container: HTMLElement): ToolbarAnchor {
  const box = container.getBoundingClientRect()
  return {
    top: rect.top - box.top + container.scrollTop,
    left: rect.left - box.left + container.scrollLeft,
    width: rect.width,
    height: rect.height,
  }
}

const itemClass =
  "flex size-8 items-center justify-center rounded-md transition-colors hover:bg-background/15 focus-visible:ring-4 focus-visible:ring-ring/60 focus-visible:outline-none aria-pressed:bg-background/25 [&_svg]:size-4"

export function FloatingToolbar({
  anchor,
  containerWidth,
  groups,
  overflow = [],
  onAction,
  onDismiss,
  label = "Text formatting",
  className,
  style,
  onKeyDown,
  ...props
}: FloatingToolbarProps) {
  const [size, setSize] = React.useState({ width: 0, height: 0 })
  const [active, setActive] = React.useState(0)
  const [menuOpen, setMenuOpen] = React.useState(false)
  const root = React.useRef<HTMLDivElement>(null)
  const more = React.useRef<HTMLButtonElement>(null)
  const focusMenu = React.useRef(false)
  const menuId = React.useId()

  const measure = React.useCallback((node: HTMLDivElement | null) => {
    root.current = node
    if (!node) return
    setSize((prev) => (prev.width === node.offsetWidth && prev.height === node.offsetHeight ? prev : { width: node.offsetWidth, height: node.offsetHeight }))
  }, [])

  React.useEffect(() => {
    if (!menuOpen) return
    if (focusMenu.current) {
      focusMenu.current = false
      root.current?.querySelector<HTMLElement>("[role='menuitem']")?.focus()
    }
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setMenuOpen(false)
    }
    document.addEventListener("pointerdown", onPointerDown)
    return () => document.removeEventListener("pointerdown", onPointerDown)
  }, [menuOpen])

  if (!anchor) return null

  const flat = groups.flat()
  const count = flat.length + (overflow.length ? 1 : 0)
  const centered = anchor.left + anchor.width / 2 - size.width / 2
  const left = Math.max(MARGIN, Math.min(centered, containerWidth - size.width - MARGIN))
  const above = anchor.top - size.height - GAP
  const top = above >= 4 ? above : anchor.top + anchor.height + GAP

  function items() {
    return Array.from(root.current?.querySelectorAll<HTMLElement>("[data-toolbar-item]") ?? [])
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event)
    const target = event.target as HTMLElement
    if (target.closest("[role='menu']")) {
      const entries = Array.from(event.currentTarget.querySelectorAll<HTMLElement>("[role='menuitem']"))
      const index = entries.indexOf(target)
      if (event.key === "ArrowDown") entries[(index + 1) % entries.length]?.focus()
      else if (event.key === "ArrowUp") entries[(index - 1 + entries.length) % entries.length]?.focus()
      else if (event.key === "Home") entries[0]?.focus()
      else if (event.key === "End") entries[entries.length - 1]?.focus()
      else if (event.key === "Escape") {
        setMenuOpen(false)
        more.current?.focus()
      } else if (event.key === "Tab") setMenuOpen(false)
      else return
      event.preventDefault()
      event.stopPropagation()
      return
    }
    const all = items()
    const index = all.indexOf(target)
    if (index < 0) return
    let next: number | null = null
    if (event.key === "ArrowRight") next = (index + 1) % all.length
    else if (event.key === "ArrowLeft") next = (index - 1 + all.length) % all.length
    else if (event.key === "Home") next = 0
    else if (event.key === "End") next = all.length - 1
    else if (event.key === "ArrowDown" && target === more.current && overflow.length) {
      event.preventDefault()
      focusMenu.current = true
      setMenuOpen(true)
      return
    } else if (event.key === "Escape") {
      event.preventDefault()
      setMenuOpen(false)
      onDismiss?.()
      return
    }
    if (next === null) return
    event.preventDefault()
    all[next].focus()
  }

  const order = [...flat.map((action) => action.id), "more"]
  const itemProps = (id: string) => {
    const index = order.indexOf(id)
    return {
      "data-toolbar-item": id,
      tabIndex: index === Math.min(active, count - 1) ? 0 : -1,
      onFocus: () => setActive(index),
    }
  }

  return (
    <div
      {...props}
      ref={measure}
      role="toolbar"
      aria-label={label}
      aria-orientation="horizontal"
      // Keep the text selection alive while the toolbar is clicked
      onMouseDown={(event) => event.preventDefault()}
      onKeyDown={handleKeyDown}
      style={{ top, left, ...style }}
      className={cn(
        "absolute z-40 flex w-max items-center gap-0.5 rounded-xl bg-foreground p-1 text-background shadow-xl animate-in fade-in zoom-in-95 duration-150 motion-reduce:animate-none",
        size.width === 0 && "invisible",
        className
      )}
    >
      {groups.map((group, groupIndex) => (
        <React.Fragment key={groupIndex}>
          {groupIndex > 0 && <span role="separator" aria-orientation="vertical" className="mx-1 h-5 w-px bg-background/25" />}
          {group.map((action) => (
            <button
              key={action.id}
              type="button"
              title={action.label}
              aria-label={action.label}
              aria-pressed={action.pressed}
              onClick={() => onAction(action.id)}
              className={itemClass}
              {...itemProps(action.id)}
            >
              {action.icon}
            </button>
          ))}
        </React.Fragment>
      ))}
      {overflow.length > 0 && (
        <>
          <span role="separator" aria-orientation="vertical" className="mx-1 h-5 w-px bg-background/25" />
          <button
            ref={more}
            type="button"
            title="More"
            aria-label="More"
            aria-haspopup="menu"
            aria-expanded={menuOpen}
            aria-controls={menuOpen ? menuId : undefined}
            onClick={() => setMenuOpen((value) => !value)}
            className={itemClass}
            {...itemProps("more")}
          >
            <Ellipsis />
          </button>
          {menuOpen && (
            <div
              id={menuId}
              role="menu"
              aria-label="More formatting"
              className="absolute top-full right-0 mt-1.5 grid w-44 gap-0.5 rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg animate-in fade-in zoom-in-95 duration-100 motion-reduce:animate-none"
            >
              {overflow.map((item, index) => (
                <button
                  key={item.id}
                  type="button"
                  role="menuitem"
                  tabIndex={index === 0 ? 0 : -1}
                  onClick={() => {
                    onAction(item.id)
                    setMenuOpen(false)
                  }}
                  className="flex h-8 items-center gap-2 rounded-md px-2 text-left text-sm transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none [&_svg]:size-4 [&_svg]:text-muted-foreground"
                >
                  {item.icon}
                  {item.label}
                </button>
              ))}
            </div>
          )}
        </>
      )}
    </div>
  )
}
```
