← All components

SelectionAsk

Select a passage and an ink pill appears above it, pointing down at what you chose.

Select any sentence

Annual plans renew at 91%, against 74% for monthly. Moving a third of monthly customers to annual would lift blended retention by about six points, which is worth more than the discount it would take to move them.

The idea

The pill is fixed to the selection's rect, so it sits over the text whatever is scrolling, and it clears on scroll — a button left floating where the text used to be is worse than none. It takes mousedown without letting it through, because otherwise pressing it would clear the very selection it is about to hand you.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

SelectionAsk — usage14 lines
import { useRef } from "react";
import { SelectionAsk } from "mihirmodi-ui";

const transcript = useRef<HTMLDivElement>(null);

<div ref={transcript}>
  {/* Mark each answer so you know where a selection came from. */}
  <div data-selection-source={message.id}>{message.body}</div>
</div>

<SelectionAsk
  container={transcript}
  onAsk={({ text, source }) => openThread({ quote: text, messageId: source })}
/>

Props

PropNotes
containerRefObject<HTMLElement>Only selections inside this element count.
onAsk(selection) => void`{ text, x, y, source? }`. `source` is the nearest `data-selection-source` — which answer it came from.
labelReactNodeThe pill's text.
minnumberShortest selection that earns the pill. A word or two is usually a misclick.
useTextSelectionhookThe same watcher without the pill, for drawing your own.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/SelectionAsk.tsx85 lines
"use client";

import { useEffect, useState, type ReactNode, type RefObject } from "react";
import { SparkleIcon } from "../lib/glyph.js";

export type TextSelection = { text: string; x: number; y: number; source?: string };

/**
 * Watches the text selection inside `container`. A selection of at least `min`
 * characters yields its text, a point above its middle to hang a button from,
 * and — if the selection sits inside an element carrying `data-selection-source`
 * — that attribute's value, so you know which answer it came from. Anything
 * else, including a scroll, clears it: a button left floating where the text
 * used to be is worse than no button.
 */
export function useTextSelection(container: RefObject<HTMLElement | null>, { min = 12, enabled = true } = {}) {
  const [sel, setSel] = useState<TextSelection | null>(null);

  useEffect(() => {
    if (!enabled) return;
    const read = () => {
      const s = window.getSelection();
      if (!s || s.isCollapsed || !s.rangeCount) return setSel(null);
      const text = s.toString().trim().replace(/\s+/g, " ");
      if (text.length < min) return setSel(null);
      const node = s.anchorNode?.nodeType === 3 ? s.anchorNode.parentElement : (s.anchorNode as Element | null);
      if (!node || !container.current?.contains(node)) return setSel(null);
      const r = s.getRangeAt(0).getBoundingClientRect();
      if (!r.width && !r.height) return setSel(null);
      const source = (node.closest("[data-selection-source]") as HTMLElement | null)?.dataset.selectionSource;
      setSel({ text, x: r.left + r.width / 2, y: r.top, source });
    };
    const hide = () => setSel(null);
    document.addEventListener("selectionchange", read);
    document.addEventListener("mouseup", read);
    document.addEventListener("scroll", hide, true);
    return () => {
      document.removeEventListener("selectionchange", read);
      document.removeEventListener("mouseup", read);
      document.removeEventListener("scroll", hide, true);
    };
  }, [container, min, enabled]);

  return sel;
}

export type SelectionAskProps = {
  /** The element whose text can be asked about. */
  container: RefObject<HTMLElement | null>;
  /** Called with the selected text (and its source, if marked). The selection is cleared after. */
  onAsk: (selection: TextSelection) => void;
  label?: ReactNode;
  /** Shortest selection that earns the button. A word or two is usually a misclick. */
  min?: number;
};

/**
 * Select a passage and a small ink pill appears above it, with a notch pointing
 * down at what you chose. Press it and you get the passage back to open a
 * thread, a comment, a search.
 *
 * It is fixed to the viewport at the selection's rect, so it sits over the text
 * whatever is scrolling. The button takes `mousedown` without letting it through
 * — otherwise pressing it would clear the very selection it is about to use.
 */
export function SelectionAsk({ container, onAsk, label = "Ask about this", min }: SelectionAskProps) {
  const sel = useTextSelection(container, { min });
  if (!sel) return null;
  return (
    <button
      type="button"
      className="selection-ask fixed z-[60] inline-flex h-[30px] -translate-x-1/2 -translate-y-full items-center gap-1.5 whitespace-nowrap rounded-full bg-ink pl-[9px] pr-2.5 text-[13px] font-medium leading-none text-background-100 shadow-[0_8px_16px_-6px_rgba(0,0,0,0.35)]"
      style={{ left: sel.x, top: sel.y - 10 }}
      onMouseDown={(e) => e.preventDefault()}
      onClick={() => {
        onAsk(sel);
        window.getSelection()?.removeAllRanges();
      }}
    >
      <SparkleIcon className="h-3 w-3" />
      {label}
      <span aria-hidden className="absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 rounded-[2px] bg-ink" />
    </button>
  );
}