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/selection-popover';
overlayselection
01

Overview

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/selection-popover';
  import { SelectionPopover } from '@lostgradient/cinder/selection-popover';

  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}
/>
Live preview
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.

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 Required Bindable Description
id text req Unique identifier for the popover.
position SelectionPopoverPositionnull req Viewport-relative anchor point for the popover.
open boolean false Whether the popover is visible.
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.