Overlays

Popover

A non-modal panel that opens beside its trigger, for details or a few controls that do not need a page.

Built on
Popover APIpopovertargetCSS anchor positioning@starting-style
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
2.7 kB
Dependencies
React
Code
import { Button } from "@/components/equip/button";
import { Popover } from "@/components/equip/popover";

export default function Demo() {
  return (
    <>
      <Button variant="outline" popoverTarget="demo-share-build">
        Share build
      </Button>
      <Popover id="demo-share-build" role="dialog" aria-label="Share build 1284">
        <p style={{ fontWeight: "var(--eq-weight-strong)" }}>Share build 1284</p>
        <p style={{ color: "var(--eq-ink-2)" }}>
          Anyone at Larder with the link can view logs until 30 November.
        </p>
        <Button size="s" variant="outline">
          Copy link
        </Button>
      </Popover>
    </>
  );
}

Examples

Placements

Each placement flips to the opposite side when there is no room, in both the CSS and the fallback path.

Opens bottom and lines up with the start edge.
Opens bottom and lines up with the end edge.
Opens top and lines up with the start edge.
Opens top and lines up with the end edge.
Code
import { Button } from "@/components/equip/button";
import { Popover, type PopoverPlacement } from "@/components/equip/popover";

const placements: PopoverPlacement[] = ["bottom-start", "bottom-end", "top-start", "top-end"];

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
      {placements.map((placement) => (
        <div key={placement}>
          <Button variant="outline" size="s" popoverTarget={`example-popover-${placement}`}>
            {placement}
          </Button>
          <Popover id={`example-popover-${placement}`} placement={placement}>
            Opens {placement.replace("-", " and lines up with the ")} edge.
          </Popover>
        </div>
      ))}
    </div>
  );
}

Details on demand

It is non-modal: Tab moves from the trigger into the popover, and a click outside or Escape closes it.

Code
import { Button } from "@/components/equip/button";
import { Popover } from "@/components/equip/popover";

const rows = [
  ["Region", "eu-west-1"],
  ["Latency", "38 ms p95"],
  ["Replicas", "3 of 3 healthy"],
];

export default function Example() {
  return (
    <>
      <Button variant="ghost" size="s" popoverTarget="example-popover-region">
        eu-west-1
      </Button>
      <Popover id="example-popover-region" placement="bottom-end" role="dialog" aria-label="Region status">
        <dl style={{ display: "grid", gridTemplateColumns: "auto auto", gap: "4px 16px", margin: 0 }}>
          {rows.map(([term, value]) => (
            <div key={term} style={{ display: "contents" }}>
              <dt style={{ color: "var(--eq-ink-2)" }}>{term}</dt>
              <dd style={{ margin: 0 }}>{value}</dd>
            </div>
          ))}
        </dl>
      </Popover>
    </>
  );
}

Props

PropTypeDefaultWhat it does
idstringnoneRequired. The trigger points at it with popoverTarget.
placement"bottom-start" | "bottom-end" | "top-start" | "top-end""bottom-start"Side of the trigger it opens on and the edge it lines up with. Flips when it does not fit.
popover"auto" | "manual" | "hint""auto"auto closes on Escape, an outside click and when another auto popover opens.
...restComponentProps<"div">noneEvery native div attribute, including role, aria-label, onToggle and ref.

Anatomy

root.eq-popoverThe popover surface, in the top layer while open.

Keyboard

EnterSpaceOn the trigger, opens or closes the popover.
TabFrom the trigger, moves focus into the open popover, then on to the rest of the page.
EscapeCloses the popover and returns focus to the trigger if focus was inside.

Notes

  • Open it from any button with popoverTarget set to its id; the trigger needs no JavaScript.
  • The browser handles Escape, outside clicks, stacking and aria-expanded on the trigger.
  • Give it role="dialog" and an aria-label when it holds controls, so screen readers announce what opened.
  • The surface positions itself from whichever trigger opened it, so several triggers can share one popover.
  • Opening and closing need no JavaScript. Positioning does: on open, the part sets anchor-name on the trigger that opened it, and CSS anchor positioning inside @supports places and flips the surface when the engine has anchor-name, position-area and position-try-fallbacks (Chromium 125, Safari 26 and Firefox 147 on, each listed as partial support). Elsewhere a small effect reads the trigger's box, sets fixed coordinates on open, flips once the size is known and follows scroll and resize while open. The exit fade needs transition of overlay, which only Chromium has; elsewhere it fades in but closes at once.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/popover.tsx121 lines
"use client";

import { useEffect, useRef, useState, type ComponentProps, type ToggleEvent } from "react";
import "./popover.css";

export type PopoverPlacement = "bottom-start" | "bottom-end" | "top-start" | "top-end";

export type PopoverProps = ComponentProps<"div"> & {
  /** Required. The trigger points at it with popovertarget. */
  id: string;
  /** Side of the trigger it opens on, and the edge it lines up with. Flips when it does not fit. */
  placement?: PopoverPlacement;
};

/* Must match the @supports condition in popover.css exactly. */
const ANCHOR_SUPPORT =
  "(anchor-name: --a) and (position-area: block-end) and (position-try-fallbacks: flip-block)";

function anchorSupported() {
  return typeof CSS !== "undefined" && CSS.supports(ANCHOR_SUPPORT);
}

/** Fallback for engines without anchor positioning: fixed coordinates from the trigger's box. */
function place(trigger: Element, surface: HTMLElement, placement: PopoverPlacement) {
  const box = trigger.getBoundingClientRect();
  const viewWidth = document.documentElement.clientWidth;
  const viewHeight = document.documentElement.clientHeight;
  const gap = parseFloat(getComputedStyle(surface).marginTop) || 0;
  let top = placement.startsWith("top");
  let end = placement.endsWith("end");

  // Size is only known once shown; flip to the other side when this one does not fit.
  const height = surface.offsetHeight + gap;
  const width = surface.offsetWidth;
  if (surface.offsetHeight > 0) {
    if (!top && box.bottom + height > viewHeight && box.top - height >= 0) top = true;
    else if (top && box.top - height < 0 && box.bottom + height <= viewHeight) top = false;
    if (!end && box.left + width > viewWidth && box.right - width >= 0) end = true;
    else if (end && box.right - width < 0 && box.left + width <= viewWidth) end = false;
  }

  const style = surface.style;
  style.top = top ? "auto" : `${box.bottom}px`;
  style.bottom = top ? `${viewHeight - box.top}px` : "auto";
  style.left = end ? "auto" : `${box.left}px`;
  style.right = end ? `${viewWidth - box.right}px` : "auto";
}

export function Popover({
  id,
  placement = "bottom-start",
  popover = "auto",
  className,
  style,
  onBeforeToggle,
  onToggle,
  ...rest
}: PopoverProps) {
  const [open, setOpen] = useState(false);
  const trigger = useRef<Element | null>(null);
  const anchorName = `--eq-popover-${id.replace(/[^\w-]/g, "")}`;

  function findTrigger() {
    const triggers = document.querySelectorAll(`[popovertarget="${CSS.escape(id)}"]`);
    for (const element of triggers) if (element === document.activeElement) return element;
    return triggers[0] ?? null;
  }

  function handleBeforeToggle(event: ToggleEvent<HTMLDivElement>) {
    onBeforeToggle?.(event);
    if (event.newState !== "open") return;
    const next = findTrigger();
    if (anchorSupported()) {
      // CSS does the positioning; it only needs to know which element is the anchor.
      if (trigger.current instanceof HTMLElement && trigger.current !== next) {
        trigger.current.style.removeProperty("anchor-name");
      }
      if (next instanceof HTMLElement) next.style.setProperty("anchor-name", anchorName);
    } else if (next) {
      place(next, event.currentTarget, placement);
    }
    trigger.current = next;
  }

  function handleToggle(event: ToggleEvent<HTMLDivElement>) {
    onToggle?.(event);
    const isOpen = event.newState === "open";
    setOpen(isOpen);
    // Second pass now that the surface has a size, so the fallback can flip.
    if (isOpen && trigger.current && !anchorSupported()) {
      place(trigger.current, event.currentTarget, placement);
    }
  }

  useEffect(() => {
    if (!open || anchorSupported()) return;
    function update() {
      const surface = document.getElementById(id);
      if (surface && trigger.current) place(trigger.current, surface, placement);
    }
    window.addEventListener("scroll", update, true);
    window.addEventListener("resize", update);
    return () => {
      window.removeEventListener("scroll", update, true);
      window.removeEventListener("resize", update);
    };
  }, [open, id, placement]);

  return (
    <div
      {...rest}
      id={id}
      popover={popover}
      className={["eq-popover", className].filter(Boolean).join(" ")}
      data-placement={placement}
      style={{ positionAnchor: anchorName, ...style }}
      onBeforeToggle={handleBeforeToggle}
      onToggle={handleToggle}
    />
  );
}
components/equip/popover.css118 lines
@layer equip {
  .eq-popover {
    --_shift: -4px;

    box-sizing: border-box;
    /* Undo the UA's centring: the surface is placed next to its trigger. */
    position: fixed;
    inset: auto;
    margin: var(--eq-space-2) 0;
    width: max-content;
    max-width: min(320px, calc(100vw - 2 * var(--eq-space-4)));
    max-height: calc(100dvh - 2 * var(--eq-space-4));
    overflow: auto;
    padding: var(--eq-space-4);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-l);
    background: var(--eq-raised);
    color: var(--eq-ink);
    box-shadow: var(--eq-shadow);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
    opacity: 0;
    scale: 0.96;
    translate: 0 var(--_shift);
    /* Grows out of the corner nearest its trigger. Leaving is quick, on the plain ease. */
    transform-origin: top left;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur) var(--eq-ease);
  }

  .eq-popover[data-placement^="top"] {
    --_shift: 4px;
  }

  .eq-popover[data-placement="bottom-end"] {
    transform-origin: top right;
  }

  .eq-popover[data-placement="top-start"] {
    transform-origin: bottom left;
  }

  .eq-popover[data-placement="top-end"] {
    transform-origin: bottom right;
  }

  .eq-popover:popover-open {
    opacity: 1;
    scale: none;
    translate: none;
    /* Arriving: the spring carries it in and lets it settle. */
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring),
      translate var(--eq-dur-move) var(--eq-spring);
  }

  @starting-style {
    .eq-popover:popover-open {
      opacity: 0;
      scale: 0.96;
      translate: 0 var(--_shift);
    }
  }

  .eq-popover > :first-child {
    margin-top: 0;
  }

  .eq-popover > :last-child {
    margin-bottom: 0;
  }

  /* Keeping the surface in the top layer while it fades out needs overlay, which only some
     engines can transition. Elsewhere it closes at once rather than fade outside the top layer. */
  @supports (overlay: auto) {
    .eq-popover {
      transition:
        opacity var(--eq-dur) var(--eq-ease),
        scale var(--eq-dur) var(--eq-ease),
        translate var(--eq-dur) var(--eq-ease),
        display var(--eq-dur) allow-discrete,
        overlay var(--eq-dur) allow-discrete;
    }

    .eq-popover:popover-open {
      transition:
        opacity var(--eq-dur) var(--eq-ease),
        scale var(--eq-dur-move) var(--eq-spring),
        translate var(--eq-dur-move) var(--eq-spring),
        display var(--eq-dur) allow-discrete,
        overlay var(--eq-dur) allow-discrete;
    }
  }

  /* Anchor positioning where the engine has it. popover.tsx uses the same condition. */
  @supports (anchor-name: --a) and (position-area: block-end) and (position-try-fallbacks: flip-block) {
    .eq-popover {
      position-area: block-end span-inline-end;
      position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
    }

    .eq-popover[data-placement="bottom-end"] {
      position-area: block-end span-inline-start;
    }

    .eq-popover[data-placement="top-start"] {
      position-area: block-start span-inline-end;
    }

    .eq-popover[data-placement="top-end"] {
      position-area: block-start span-inline-start;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index