Overlays

SelectionPopover

Floating toolbar anchored to a text selection that exposes a comment-on-selection action with an inline composer.

import { SelectionPopover } from '@lostgradient/cinder';
overlayselection
01

Overview

Reference usage

A floating toolbar that appears near a text selection and offers a comment-on-selection action with an inline composer. The component uses fixed-position Floating UI geometry, so the supplied selection anchor can flip or shift when it is near a viewport edge.

Choosing this component

Use SelectionPopover when you want readers to annotate or comment on a highlighted range of text — for example, in a document editor, review tool, or article surface. It is designed specifically for selection-scoped actions. For generic floating content or popovers unrelated to text selection, use the Popover component instead.

SelectionPopover does not render persistent comment highlights. Keep saved comment anchors in your own markup, or use ReviewEditor and the commentary anchoring utilities when you need editor-backed anchor tracking.

How it positions

The position prop accepts a viewport-relative anchor point — the same coordinate space returned by Range.getClientRects() and Range.getBoundingClientRect(). A typical consumer computes the anchor from the selected range and passes it directly:

ts
const range = selection.getRangeAt(0);
const rect =
  Array.from(range.getClientRects()).find((clientRect) => {
    return clientRect.width > 0 && clientRect.height > 0;
  }) ?? range.getBoundingClientRect();
position = { x: rect.left + rect.width / 2, y: rect.top, height: rect.height };

The component treats that point as a Floating UI virtual anchor, not as the panel's top-left corner. It prefers an above-selection placement, but shifts or flips near viewport edges. Coordinates are not relative to a containing element — do not pass offsetLeft/offsetTop or any container-relative value.

Usage

svelte
<script lang="ts">
  import { onMount } from 'svelte';
  import type { SelectionPopoverPosition } from '@lostgradient/cinder';
  import { SelectionPopover } from '@lostgradient/cinder';

  type Comment = { id: string; body: string };

  let isOpen = $state(false);
  let position = $state<SelectionPopoverPosition | null>(null);
  let comments = $state<Comment[]>([]);

  /**
   * Bound reference to the text surface element.
   * `selectionchange` is a document-level event — the handler uses this
   * reference to scope detection to selections inside the surface only.
   */
  let surfaceElement = $state<HTMLElement | null>(null);

  onMount(() => {
    function handleSelectionChange(): void {
      if (!surfaceElement) return;
      const selection = window.getSelection();
      if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
        isOpen = false;
        position = null;
        return;
      }
      const range = selection.getRangeAt(0);
      if (!surfaceElement.contains(range.commonAncestorContainer)) {
        isOpen = false;
        position = null;
        return;
      }
      const rect =
        Array.from(range.getClientRects()).find((clientRect) => {
          return clientRect.width > 0 && clientRect.height > 0;
        }) ?? range.getBoundingClientRect();
      if (rect.width === 0 && rect.height === 0) {
        isOpen = false;
        position = null;
        return;
      }
      position = { x: rect.left + rect.width / 2, y: rect.top, height: rect.height };
      isOpen = true;
    }

    document.addEventListener('selectionchange', handleSelectionChange);
    return () => document.removeEventListener('selectionchange', handleSelectionChange);
  });

  function handleClose(): void {
    isOpen = false;
    position = null;
  }

  function handleCommentSubmit(body: string): void {
    comments = [...comments, { id: crypto.randomUUID(), body }];
    handleClose();
  }
</script>

<article bind:this={surfaceElement}>
  <p>Select text in this paragraph to comment on it.</p>
</article>

<SelectionPopover
  id="my-selection-popover"
  open={isOpen}
  {position}
  onClose={handleClose}
  onCommentSubmit={handleCommentSubmit}
/>

Custom actions

By default the popover offers the comment flow: an "Add comment" button that expands into an inline composer. Pass an actions snippet to replace that flow with your own controls, such as a quote action. The snippet is called with { close }; calling close dismisses the popover through the same path as Escape and an outside click, so onClose fires.

svelte
<SelectionPopover
  id="quote-popover"
  open={isOpen}
  {position}
  label="Quote selection"
  onClose={handleClose}
>
  {#snippet actions({ close })}
    <Button
      size="sm"
      variant="ghost"
      label="Quote"
      onclick={() => {
        quoteSelection();
        close();
      }}
    />
  {/snippet}
</SelectionPopover>

With actions given:

  • onCommentSubmit and onExpand are never called, and Enter and Space no longer expand the popover.
  • Focus moves to the first action once the popover is positioned and open. If a pointer is still down, as in a drag-select, the move waits for the pointer to be released so the selection is not cut short. Open the popover once the selection gesture has finished where you can (for example on pointerup or selectionchange after a drag); keyboard selection with Shift and the arrow keys has no such release, so the move takes focus as soon as the popover is positioned.
  • The actions are one Tab stop. Left and Right arrows move focus between them, and Home and End jump to the first and last. This is the same implementation Toolbar uses, so editable controls keep Home and End and let Left and Right move focus only at the matching caret boundary, and disabling or enabling an action re-computes the Tab stop.
  • On close, focus returns to the element that had it before the popover opened. After a mouse selection that is usually the page body, so name a specific element yourself in onClose if focus should land somewhere else.
  • Escape and a pointer press outside call onClose, exactly as without actions.
  • Selection tracking stays with the caller, as in the other examples.

The label prop sets the toolbar's accessible name and defaults to "Selection actions". Give it a name that fits the actions you render.

Live preview

Selection-driven comment popover

The SelectionPopover appears near highlighted text — its position prop takes viewport-relative coordinates derived from Range.getClientRects(), not local container offsets. Select any portion of this paragraph to see it appear near your selection.

Clicking the comment icon expands the composer. Submit with the send button or Cmd/Ctrl+Enter. Clicking outside the popover closes it and resets the popover state.

02

When to use

Use when
  • Letting readers annotate or comment on a highlighted range of text in a document or article surface.
  • Surfacing selection-scoped actions such as quote, share, or comment near the user's pointer.
Avoid when
  • Anchoring generic non-selection content to a trigger — use popover.
  • Building a general-purpose floating toolbar unrelated to text selection — compose a popover with custom controls.
03

Examples

Selection-driven comment popover

Highlight text in the passage below to trigger the popover at the selection anchor. Position is viewport-relative geometry derived from the selected range.

Existing commented selections

Shows persistent comment highlights as consumer-owned markup while SelectionPopover handles new text selections.

Keyboard submit after selection

Select text (by mouse or keyboard) to open the composer, then press Cmd+Enter or Ctrl+Enter to submit. The composer collapses back to the icon — the popover does not stay expanded.

Null position clears the popover

Setting position to null hides the popover even while open is true — the consumer can clear an active selection without changing the open flag.

Quote action with custom actions

The actions snippet replaces the built-in comment flow. Select text to open a Quote action; activating it captures the selection and calls close.

Toggled by an external trigger

A button outside the popover drives open. When the consumer clicks outside the popover, the component invokes onClose and the consumer flips open back to false.

Viewport edge handling

Shows that out-of-bounds selection anchors are shifted or flipped back inside the viewport.

04

Props

Props for selection-popover
Name
Type
Default
Description
id required text Unique identifier for the popover.
position required { x: number; y: number; /** * Height of the selection rect in CSS pixels. When provided, the virtual * anchor exposes the correct `bottom` edge (`y …
Show full type { x: number; y: number; /** * Height of the selection rect in CSS pixels. When provided, the virtual * anchor exposes the correct `bottom` edge (`y + height`) to floating-ui so * that `flip` places the panel below the selection's actual bottom edge * instead of overlapping it. Defaults to 0 when omitted (zero-height point * anchor — the original behaviour, which causes overlap when flipped). */ height?: number; }
Viewport-relative anchor point for the popover.
open boolean false Whether the popover is visible.
label text 'Selection actions' Accessible name of the toolbar.
actions snippet Caller-supplied actions that replace the built-in comment button and inline composer. Called with { close }; close dismisses the popover through the same path as Escape and an outside click, so onClose fires. When given, onCommentSubmit and onExpand are never called, Enter and Space no longer expand the popover, and focus moves to the first action when the popover opens.
onCommentSubmit (body: string) => void Called when a comment is submitted.
onExpand () => void Called when the compact action expands into the composer.
onCancel () => void Called when the composer is canceled.
onClose () => void Called when the popover should close.
onExitComplete () => void Called once the popover's exit transition has genuinely finished and it has fully unmounted from the layout (see _internal/OVERLAY-POLICY.md § "Transition lifecycle"). This component intentionally never unmounts ITS OWN root element while closing — data-cinder-visible/ data-cinder-closing drive the retained fade instead of an {#if} gate — so a consumer that wraps this component in its own {#if} keyed directly on the same state that flips open false would destroy the whole component instance before its exit transition ever gets a chance to play. Use this callback to decouple that wrapping condition from the live open state: keep the consumer's own mount gate true until this fires, then clear it.