Popover

Anchored panel holding secondary controls or detail: a filter set, a field explanation,

Feedback Popover.jsx Popover.d.ts Popover.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Popover is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Popover } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

Anchored panel holding secondary controls or detail: a filter set, a field explanation, a small form. Opens on click, closes on outside click or Escape.

Rules

  • label is required. The panel is a dialog; an unnamed dialog is announced as nothing.
  • Click, not hover. Hover-opened panels containing controls are unusable on touch and hostile with a trackpad.
  • Keep it to one job. A popover with tabs inside it should be a Drawer or a Dialog.
  • Do not nest popovers. The second one traps focus behind the first.

Surfaces

surface="glass" (or "glass-strong", which lets less through) lets what's behind show through, blurred. Use it over a picture or a busy screen; keep raised (default) over plain content, where glass only costs contrast.

Tradeoffs

Popovers stay anchored to their trigger, which keeps context but constrains size. Once content exceeds roughly 320px tall, a Drawer gives the user a scrollable surface and a clear way out.

Props

The typed contract, from Popover.d.ts.

import * as React from "react";

/** Anchored panel for secondary content and controls. */
export interface PopoverProps extends React.HTMLAttributes<HTMLSpanElement> {
  /** The element that opens the popover. Cloned with aria-expanded. */
  trigger: React.ReactNode;
  children?: React.ReactNode;
  /** Controlled open state. Omit to let the component manage it. */
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** @default "bottom-start" */
  placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
  /** Accessible name for the panel. Required. */
  label: string;
  /** @default 260 */
  width?: number | string;
  /** "glass" lets what's behind show through, blurred (--dt-surface-glass with --dt-backdrop-glass); "glass-strong" lets less through. Use over a picture or a busy screen. @default "raised" */
  surface?: "raised" | "glass" | "glass-strong";
}

export declare function Popover(props: PopoverProps): React.JSX.Element;

Tokens it reads

Every custom property Popover.jsx resolves, read from the source: 14 semantic. Component tier first, because that is the one to reach for.

  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-backdrop-glasssemanticsaturate(1.6) blur(var(--dt-blur-glass))
--dt-border-glasssemanticcolor-mix(in oklab, var(--dt-text-primary) 10%, transparent)
--dt-border-subtlesemanticvar(--dt-color-neutral-100)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-elevation-3semanticvar(--dt-shadow-raw-3)
--dt-radius-overlaysemanticvar(--dt-radius-raw-24)
--dt-space-inset-mdsemanticvar(--dt-dim-4)
--dt-surface-glasssemanticcolor-mix(in oklab, var(--dt-surface-overlay) 72%, transparent)
--dt-surface-glass-strongsemanticcolor-mix(in oklab, var(--dt-surface-overlay) 88%, transparent)
--dt-surface-raisedsemanticvar(--dt-color-white)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-primarysemanticvar(--dt-color-neutral-900)

Source

React only, no dependencies, every value a token.

import React from "react";

/* Glass: the overlay surface let through, blurred, for a panel over a
   picture or a busy screen. Always paired with the backdrop filter. */
const GLASS = { glass: "var(--dt-surface-glass)", "glass-strong": "var(--dt-surface-glass-strong)" };

export function Popover({ trigger, children, open: controlled, onOpenChange, placement = "bottom-start", label, width = 260, surface = "raised", style, ...rest }) {
  const [uncontrolled, setUncontrolled] = React.useState(false);
  const open = controlled != null ? controlled : uncontrolled;
  const setOpen = v => { if (controlled == null) setUncontrolled(v); onOpenChange && onOpenChange(v); };
  const wrap = React.useRef(null);
  React.useEffect(() => {
    if (!open) return;
    const onDown = e => { if (wrap.current && !wrap.current.contains(e.target)) setOpen(false); };
    const onKey = e => e.key === "Escape" && setOpen(false);
    document.addEventListener("mousedown", onDown);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
  }, [open]);
  const pos = {
    "bottom-start": { top: "calc(100% + 6px)", left: 0 },
    "bottom-end": { top: "calc(100% + 6px)", right: 0 },
    "top-start": { bottom: "calc(100% + 6px)", left: 0 },
    "top-end": { bottom: "calc(100% + 6px)", right: 0 },
  }[placement];
  return (
    <span ref={wrap} style={{ position: "relative", display: "inline-flex", ...style }} {...rest}>
      {React.isValidElement(trigger)
        ? React.cloneElement(trigger, { onClick: e => { trigger.props.onClick && trigger.props.onClick(e); setOpen(!open); }, "aria-expanded": open, "aria-haspopup": "dialog" })
        : trigger}
      {open && (
        <div role="dialog" aria-label={label} style={{
          position: "absolute", ...pos, zIndex: 30, width, boxSizing: "border-box",
          background: GLASS[surface] || "var(--dt-surface-raised)", color: "var(--dt-text-primary)",
          backdropFilter: GLASS[surface] ? "var(--dt-backdrop-glass)" : undefined,
          WebkitBackdropFilter: GLASS[surface] ? "var(--dt-backdrop-glass)" : undefined,
          border: `var(--dt-border-width-default) solid ${GLASS[surface] ? "var(--dt-border-glass)" : "var(--dt-border-subtle)"}`,
          borderRadius: "var(--dt-radius-overlay)", boxShadow: "var(--dt-elevation-3)",
          padding: "var(--dt-space-inset-md)",
          fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)",
          lineHeight: "var(--dt-text-body-sm-line)",
        }}>{children}</div>
      )}
    </span>
  );
}

In context

Popover also appears on the feedback reference cards, alongside the rest of its family.