Overlays

Hover card

A richer preview of a person, repository or document, shown when its link is hovered or focused.

Built on
Popover APIpopover="hint"CSS anchor positioning@starting-style
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.4 kB
Dependencies
React

Review requested from @miraMira OkaforDesign systems, LisbonMaintains the token pipeline. 214 reviews this year. before the release branch is cut.

Code
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/equip/hover-card";

const link = {
  color: "var(--eq-ink)",
  fontWeight: "var(--eq-weight-strong)",
  textDecorationColor: "var(--eq-line-strong)",
  textUnderlineOffset: 3,
} as const;

export default function Demo() {
  return (
    <p style={{ maxWidth: 210, margin: 0, fontSize: "var(--eq-text-s)", lineHeight: 1.5, color: "var(--eq-ink-2)" }}>
      Review requested from{" "}
      <HoverCard id="demo-hover-card-mira">
        <HoverCardTrigger>
          <a href="#people/mira" style={link}>
            @mira
          </a>
        </HoverCardTrigger>
        <HoverCardContent>
          <span style={{ display: "flex", gap: 12, alignItems: "center" }}>
            <span
              aria-hidden="true"
              style={{
                display: "grid",
                placeItems: "center",
                width: 36,
                height: 36,
                borderRadius: "var(--eq-radius-full)",
                background: "var(--eq-tint-strong)",
                fontWeight: "var(--eq-weight-strong)",
              }}
            >
              MO
            </span>
            <span style={{ display: "grid" }}>
              <strong>Mira Okafor</strong>
              <span style={{ color: "var(--eq-ink-2)" }}>Design systems, Lisbon</span>
            </span>
          </span>
          <span style={{ display: "block", marginTop: 10, color: "var(--eq-ink-2)" }}>
            Maintains the token pipeline. 214 reviews this year.
          </span>
        </HoverCardContent>
      </HoverCard>{" "}
      before the release branch is cut.
    </p>
  );
}

Examples

Repository with a link inside

Tab from the trigger moves into the card, and Tab again moves on through the page. Nothing is trapped.

Forked from larder/ledgerlarder/ledgerDouble-entry bookkeeping for Postgres, with migrations and an audit trail.TypeScript2.1k starsUpdated 3 days ago38 open issues

Code
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/equip/hover-card";

export default function Example() {
  return (
    <p style={{ margin: 0, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>
      Forked from{" "}
      <HoverCard id="example-hover-card-repo">
        <HoverCardTrigger>
          <a href="#repos/larder/ledger" style={{ color: "var(--eq-ink)", textUnderlineOffset: 3 }}>
            larder/ledger
          </a>
        </HoverCardTrigger>
        <HoverCardContent>
          <strong style={{ display: "block" }}>larder/ledger</strong>
          <span style={{ display: "block", marginTop: 4, color: "var(--eq-ink-2)" }}>
            Double-entry bookkeeping for Postgres, with migrations and an audit trail.
          </span>
          <span style={{ display: "flex", gap: 12, marginTop: 10, fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>
            <span>TypeScript</span>
            <span>2.1k stars</span>
            <span>Updated 3 days ago</span>
          </span>
          <a
            href="#repos/larder/ledger/issues"
            style={{ display: "inline-block", marginTop: 10, color: "var(--eq-ink)", textUnderlineOffset: 3 }}
          >
            38 open issues
          </a>
        </HoverCardContent>
      </HoverCard>
    </p>
  );
}

Previews in a list

Each link still opens the document on its own, which is all a touch user gets.

Code
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/equip/hover-card";

const docs = [
  { id: "rfc-12", title: "RFC 12: Rate limits per workspace", owner: "Ana Ruiz", status: "In review", words: "1,840 words" },
  { id: "rfc-13", title: "RFC 13: Retire the v1 webhooks", owner: "Kenji Sato", status: "Accepted", words: "960 words" },
];

export default function Example() {
  return (
    <ul style={{ margin: 0, paddingLeft: 18, fontSize: "var(--eq-text-s)", lineHeight: 1.9 }}>
      {docs.map((doc) => (
        <li key={doc.id}>
          <HoverCard id={`example-hover-card-${doc.id}`}>
            <HoverCardTrigger>
              <a href={`#docs/${doc.id}`} style={{ color: "var(--eq-ink)", textUnderlineOffset: 3 }}>
                {doc.title}
              </a>
            </HoverCardTrigger>
            <HoverCardContent>
              <strong style={{ display: "block" }}>{doc.title}</strong>
              <span style={{ display: "block", color: "var(--eq-ink-2)" }}>
                {doc.owner}, {doc.status.toLowerCase()}, {doc.words}
              </span>
            </HoverCardContent>
          </HoverCard>
        </li>
      ))}
    </ul>
  );
}

Props

PropTypeDefaultWhat it does
idstringnoneHoverCard: required. Given to the card.
childrenReactNodenoneHoverCardTrigger: one link or button that leads somewhere useful on its own. HoverCardContent: phrasing content.
...restComponentProps<"span">noneEvery native span attribute. On HoverCardContent they reach the card itself.

Anatomy

root.eq-hover-cardHoverCard, an inline span holding the trigger and the card.
trigger.eq-hover-card-triggerAn inline wrapper around the link; also the anchor.
content.eq-hover-card-contentThe card, a popover in the top layer. Its ::before is the hover bridge over the gap.

Keyboard

TabFocusing the trigger opens the card at once; Tab again moves into it, and leaving both closes it.
EscapeCloses the card and returns focus to the trigger if it was inside.

Notes

  • It opens after the pointer rests 500 ms on the trigger, at once on keyboard focus, and closes 200 ms after the pointer leaves both.
  • Touch users never see it, so the trigger must lead to the same information on its own, such as the profile page.
  • It never holds anything that is not available elsewhere, and screen readers are not told about it; it is a shortcut, not a source.
  • It never traps focus: the card sits right after the trigger in tab order and closes when focus leaves both.
  • Scrolling the page closes it, so it never floats away from its trigger.
  • Everything is a span, so it can sit inside a sentence; put phrasing content in the card, or use spans with display: block.
  • popover="hint" is used where the engine has it, so opening a card leaves menus open and closes other hints; elsewhere it is a manual popover with the same Escape, leave and scroll closing. CSS anchor positioning is used when the engine has anchor-name, position-area and position-try-fallbacks; elsewhere the card is placed under the trigger from its box when it opens, kept in the viewport and flipped above when there is no room. The exit fade needs transition of overlay, which only Chromium has.

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/hover-card.tsx240 lines
"use client";

import {
  createContext,
  useContext,
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentProps,
  type FocusEvent,
  type PointerEvent,
  type ReactNode,
  type RefObject,
  type ToggleEvent,
} from "react";
import "./hover-card.css";

const OPEN_DELAY = 500;
const CLOSE_DELAY = 200;

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

type Card = {
  id: string;
  anchorName: string;
  trigger: RefObject<HTMLSpanElement | null>;
  open: (delay: number) => void;
  close: (delay: number) => void;
  cancel: () => void;
};

const CardContext = createContext<Card | null>(null);

function useCard(name: string) {
  const card = useContext(CardContext);
  if (!card) throw new Error(`${name} must be inside a HoverCard.`);
  return card;
}

/** Fallback for engines without anchor positioning: below the trigger, flipped above when there is no room. */
function place(anchor: Element, card: HTMLElement) {
  const box = anchor.getBoundingClientRect();
  const viewWidth = document.documentElement.clientWidth;
  const viewHeight = document.documentElement.clientHeight;
  const gap = parseFloat(getComputedStyle(card).marginTop) || 0;
  const height = card.offsetHeight + gap;
  const below = box.bottom + height <= viewHeight || box.top - height < 0;
  const edge = 8;
  const style = card.style;
  style.left = `${Math.max(edge, Math.min(box.left, viewWidth - card.offsetWidth - edge))}px`;
  style.right = "auto";
  style.top = below ? `${box.bottom}px` : "auto";
  style.bottom = below ? "auto" : `${viewHeight - box.top}px`;
}

export type HoverCardProps = ComponentProps<"span"> & {
  /** Required. Given to the card, so other markup can refer to it. */
  id: string;
};

/**
 * Holds a trigger and its card. It renders an inline span, so it can sit in a sentence.
 */
export function HoverCard({ id, className, onBlur, children, ...rest }: HoverCardProps) {
  const anchorName = `--eq-hover-card-${useId().replace(/[^\w-]/g, "")}`;
  const trigger = useRef<HTMLSpanElement>(null);
  const timer = useRef<number | undefined>(undefined);

  useEffect(() => () => window.clearTimeout(timer.current), []);

  function show() {
    const card = document.getElementById(id);
    const anchor = trigger.current?.firstElementChild ?? trigger.current;
    if (!card || !anchor || card.matches(":popover-open")) return;
    const fallback = !CSS.supports(ANCHOR_SUPPORT);
    if (fallback) place(anchor, card);
    card.showPopover();
    if (fallback) place(anchor, card);
  }

  function hide() {
    const card = document.getElementById(id);
    if (card?.matches(":popover-open")) card.hidePopover();
  }

  const value: Card = {
    id,
    anchorName,
    trigger,
    open(delay) {
      window.clearTimeout(timer.current);
      if (delay === 0) show();
      else timer.current = window.setTimeout(show, delay);
    },
    close(delay) {
      window.clearTimeout(timer.current);
      if (delay === 0) hide();
      else timer.current = window.setTimeout(hide, delay);
    },
    cancel() {
      window.clearTimeout(timer.current);
    },
  };

  // Focus that leaves both the trigger and the card closes it. Moving into the card does not.
  function handleBlur(event: FocusEvent<HTMLSpanElement>) {
    onBlur?.(event);
    if (!event.currentTarget.contains(event.relatedTarget)) value.close(0);
  }

  return (
    <CardContext.Provider value={value}>
      <span {...rest} className={["eq-hover-card", className].filter(Boolean).join(" ")} onBlur={handleBlur}>
        {children}
      </span>
    </CardContext.Provider>
  );
}

export type HoverCardTriggerProps = Omit<ComponentProps<"span">, "children"> & {
  /** One link or button. It should lead somewhere useful on its own, since touch users never see the card. */
  children: ReactNode;
};

export function HoverCardTrigger({
  className,
  style,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  children,
  ...rest
}: HoverCardTriggerProps) {
  const card = useCard("HoverCardTrigger");

  return (
    <span
      ref={card.trigger}
      {...rest}
      className={["eq-hover-card-trigger", className].filter(Boolean).join(" ")}
      style={{ anchorName: card.anchorName, ...style }}
      onPointerEnter={(event: PointerEvent<HTMLSpanElement>) => {
        onPointerEnter?.(event);
        if (event.pointerType === "mouse") card.open(OPEN_DELAY);
      }}
      onPointerLeave={(event: PointerEvent<HTMLSpanElement>) => {
        onPointerLeave?.(event);
        if (event.pointerType === "mouse") card.close(CLOSE_DELAY);
      }}
      onFocus={(event: FocusEvent<HTMLSpanElement>) => {
        onFocus?.(event);
        // Keyboard focus opens it at once; a click already had its hover.
        if (event.target.matches(":focus-visible")) card.open(0);
      }}
    >
      {children}
    </span>
  );
}

export type HoverCardContentProps = ComponentProps<"span">;

/**
 * The card. A span with display: block, so it is valid inside a paragraph: fill it with
 * phrasing content (spans, links, strong, img).
 */
export function HoverCardContent({
  className,
  style,
  onToggle,
  onPointerEnter,
  onPointerLeave,
  children,
  ...rest
}: HoverCardContentProps) {
  const card = useCard("HoverCardContent");
  const [open, setOpen] = useState(false);
  const [mode, setMode] = useState<"manual" | "hint">("manual");

  // popover="hint" lets a hint close other hints but leave menus open. Engines without it
  // would read the value as manual anyway; asking first keeps the server render stable.
  useEffect(() => {
    const probe = document.createElement("span");
    probe.setAttribute("popover", "hint");
    if (probe.popover === "hint") setMode("hint");
  }, []);

  // While open: Escape closes and hands focus back to the trigger; any page scroll closes it.
  useEffect(() => {
    if (!open) return;
    const element = document.getElementById(card.id);
    function onKeyDown(event: KeyboardEvent) {
      if (event.key !== "Escape") return;
      event.preventDefault();
      const trigger = card.trigger.current?.firstElementChild;
      if (element?.contains(document.activeElement) && trigger instanceof HTMLElement) trigger.focus();
      card.close(0);
    }
    function onScroll(event: Event) {
      if (event.target instanceof Node && element?.contains(event.target)) return;
      card.close(0);
    }
    document.addEventListener("keydown", onKeyDown, true);
    // Listen from the next frame, so the scroll that focusing the trigger caused does not count.
    const frame = requestAnimationFrame(() =>
      window.addEventListener("scroll", onScroll, { capture: true, passive: true }),
    );
    return () => {
      cancelAnimationFrame(frame);
      document.removeEventListener("keydown", onKeyDown, true);
      window.removeEventListener("scroll", onScroll, { capture: true });
    };
  }, [open, card]);

  return (
    <span
      {...rest}
      id={card.id}
      popover={mode}
      className={["eq-hover-card-content", className].filter(Boolean).join(" ")}
      style={{ positionAnchor: card.anchorName, ...style }}
      onToggle={(event: ToggleEvent<HTMLSpanElement>) => {
        onToggle?.(event);
        setOpen(event.newState === "open");
      }}
      onPointerEnter={(event: PointerEvent<HTMLSpanElement>) => {
        onPointerEnter?.(event);
        card.cancel();
      }}
      onPointerLeave={(event: PointerEvent<HTMLSpanElement>) => {
        onPointerLeave?.(event);
        if (event.pointerType === "mouse") card.close(CLOSE_DELAY);
      }}
    >
      {children}
    </span>
  );
}
components/equip/hover-card.css91 lines
@layer equip {
  .eq-hover-card,
  .eq-hover-card-trigger {
    display: inline;
  }

  .eq-hover-card-content {
    box-sizing: border-box;
    /* Undo the UA's centring: the card is placed against its trigger. */
    position: fixed;
    inset: auto;
    margin: var(--eq-space-2) 0;
    width: max-content;
    max-width: min(300px, calc(100vw - 2 * var(--eq-space-2)));
    padding: var(--eq-space-4);
    overflow: visible;
    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-s) / 1.45 var(--eq-font);
    text-align: start;
    opacity: 0;
    scale: 0.96;
    translate: 0 -4px;
    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);
  }

  /* The hover bridge: an invisible strip over the gap, so the pointer can cross from the
     trigger to the card without leaving both. */
  .eq-hover-card-content::before {
    content: "";
    position: absolute;
    z-index: -1;
    inset: calc(-1 * var(--eq-space-2) - 1px) 0;
  }

  .eq-hover-card-content:popover-open {
    opacity: 1;
    scale: none;
    translate: none;
    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-hover-card-content:popover-open {
      opacity: 0;
      scale: 0.96;
      translate: 0 -4px;
    }
  }

  /* The exit fade needs the card kept in the top layer while it runs. */
  @supports (overlay: auto) {
    .eq-hover-card-content {
      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-hover-card-content: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. hover-card.tsx uses the same condition. */
  @supports (anchor-name: --a) and (position-area: bottom) and (position-try-fallbacks: flip-block) {
    .eq-hover-card-content {
      position-area: bottom span-right;
      position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
    }
  }
}

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