Monet
@etched/monet 0.0.1

Hover card

Ported

A card of extra detail that opens on hover and on keyboard focus, after a delay: notes, activations, references. Keyboard users see it but cannot tab into it, and screen readers do not announce it, so its content must also be reachable another way, such as the trigger's own page. ref goes to the trigger.

2 notes
import { Link } from "@etched/monet/link";
import { Text } from "@etched/monet/text";
import { HoverCard } from "@etched/monet/hover-card";

interface Note {
  author: string;
  date: string;
  text: string;
}

const notes: Note[] = [
  { author: "Sam Lee", date: "Oct 14", text: "Strong on clock trees. Wants to stay close to silicon bring-up." },
  { author: "Kai Ito", date: "Oct 9", text: "Asked about the on-call rotation and the Taipei trips." },
];

function NoteList({ items }: { items: Note[] }) {
  return (
    <div style={{ display: "grid", gap: "var(--et-space-3)", inlineSize: "18rem" }}>
      {items.map((note) => (
        <div key={note.date}>
          <Text as="p" size="sm" variant="secondary">
            {note.author} · {note.date}
          </Text>
          <Text as="p" size="sm">
            {note.text}
          </Text>
        </div>
      ))}
    </div>
  );
}

export function Preview() {
  return (
    <HoverCard content={<NoteList items={notes} />} render={<Link href="#notes" />}>
      2 notes
    </HoverCard>
  );
}

Installation

import { HoverCard } from "@etched/monet/hover-card";

Examples

Notes

It opens on hover or keyboard focus after 600 ms, and closes 150 ms after the pointer leaves. The link's click stays its own.

Ana Ruiz has 2 notes from the last loop.

import { Link } from "@etched/monet/link";
import { Text } from "@etched/monet/text";
import { HoverCard } from "@etched/monet/hover-card";

interface Note {
  author: string;
  date: string;
  text: string;
}

const notes: Note[] = [
  { author: "Sam Lee", date: "Oct 14", text: "Strong on clock trees. Wants to stay close to silicon bring-up." },
  { author: "Kai Ito", date: "Oct 9", text: "Asked about the on-call rotation and the Taipei trips." },
];

function NoteList({ items }: { items: Note[] }) {
  return (
    <div style={{ display: "grid", gap: "var(--et-space-3)", inlineSize: "18rem" }}>
      {items.map((note) => (
        <div key={note.date}>
          <Text as="p" size="sm" variant="secondary">
            {note.author} · {note.date}
          </Text>
          <Text as="p" size="sm">
            {note.text}
          </Text>
        </div>
      ))}
    </div>
  );
}

export function Notes() {
  return (
    <p>
      Ana Ruiz has{" "}
      <HoverCard content={<NoteList items={notes} />} render={<Link href="#notes" />}>
        2 notes
      </HoverCard>{" "}
      from the last loop.
    </p>
  );
}

Anchored to the row

anchor places the card against another element, here the whole row, so the card lines up with the row's start.

Ana Ruiz, design verification:Interview notes
import { useRef } from "react";
import { Link } from "@etched/monet/link";
import { Text } from "@etched/monet/text";
import { HoverCard } from "@etched/monet/hover-card";

interface Note {
  author: string;
  date: string;
  text: string;
}

const notes: Note[] = [
  { author: "Sam Lee", date: "Oct 14", text: "Strong on clock trees. Wants to stay close to silicon bring-up." },
  { author: "Kai Ito", date: "Oct 9", text: "Asked about the on-call rotation and the Taipei trips." },
];

function NoteList({ items }: { items: Note[] }) {
  return (
    <div style={{ display: "grid", gap: "var(--et-space-3)", inlineSize: "18rem" }}>
      {items.map((note) => (
        <div key={note.date}>
          <Text as="p" size="sm" variant="secondary">
            {note.author} · {note.date}
          </Text>
          <Text as="p" size="sm">
            {note.text}
          </Text>
        </div>
      ))}
    </div>
  );
}

export function AnchoredToTheRow() {
  const row = useRef<HTMLDivElement>(null);

  return (
    <div ref={row} className="row" style={{ maxInlineSize: "28rem", padding: "var(--et-space-2) var(--et-space-3)", boxShadow: "inset 0 0 0 1px var(--et-line)" }}>
      Ana Ruiz, design verification:
      <HoverCard anchor={row} content={<NoteList items={notes} />} render={<Link href="#notes" />}>
        Interview notes
      </HoverCard>
    </div>
  );
}

Long content scrolls

The card is at most 24 rem high; the rest scrolls inside it.

import { HoverCard } from "@etched/monet/hover-card";

const activations = Array.from({ length: 30 }, (_, i) => `Run ${1284 - i} ${i % 5 ? "passed" : "failed at 71 °C"}`);

export function LongContentScrolls() {
  return (
    <HoverCard
      side="right"
      content={
        <ol style={{ margin: 0, paddingInlineStart: "var(--et-space-5)" }}>
          {activations.map((activation) => (
            <li key={activation}>{activation}</li>
          ))}
        </ol>
      }
    >
      30 activations
    </HoverCard>
  );
}

Wide card

size="lg" is a fixed 32 rem card for long previews, such as a submitted referral.

import { Text } from "@etched/monet/text";
import { HoverCard } from "@etched/monet/hover-card";

interface Note {
  author: string;
  date: string;
  text: string;
}

const notes: Note[] = [
  { author: "Sam Lee", date: "Oct 14", text: "Strong on clock trees. Wants to stay close to silicon bring-up." },
  { author: "Kai Ito", date: "Oct 9", text: "Asked about the on-call rotation and the Taipei trips." },
];

function NoteList({ items }: { items: Note[] }) {
  return (
    <div style={{ display: "grid", gap: "var(--et-space-3)", inlineSize: "18rem" }}>
      {items.map((note) => (
        <div key={note.date}>
          <Text as="p" size="sm" variant="secondary">
            {note.author} · {note.date}
          </Text>
          <Text as="p" size="sm">
            {note.text}
          </Text>
        </div>
      ))}
    </div>
  );
}

export function WideCard() {
  return (
    <HoverCard size="lg" content={<NoteList items={notes} />}>
      Referral from Sam Lee
    </HoverCard>
  );
}

Accessibility

  • Focus on the trigger opens the card after delay (600 ms by default). Escape closes it. Tab does not go into the card.
  • The trigger is a plain button named by its children, or the element in render, such as a link. The card has no role.
  • Screen readers do not announce the card, and the trigger gets no aria-expanded or description for it.
  • Put nothing in the card that people need and cannot reach another way, such as on the trigger's own page.

API reference

PropTypeDefaultDescription
positionMethodBaseTooltip.Positioner.Props["positionMethod"]"absolute""fixed" escapes sticky headers and other stacking contexts.
anchorBaseTooltip.Positioner.Props["anchor"]–Position against this element, ref or virtual element instead of the trigger, for example a whole row.
containerBaseTooltip.Portal.Props["container"]document.bodyThe element the popup renders into (a portal), for example a shadow root.
content *ReactNode–The card. The pointer can move onto it, so links and selectable text in it work. Long content scrolls.
side"top" | "bottom" | "left" | "right""bottom"
align"start" | "center" | "end""start"
sideOffsetnumber8Pixels between the trigger and the card, as on Tooltip, Popover and the menus.
size"base" | "lg""base"lg is a fixed 32 rem card for long previews, such as a submitted referral. base fits its content up to 22 rem.
delaynumber600Milliseconds before it opens on hover or keyboard focus.
closeDelaynumber150Milliseconds before it closes after the pointer leaves the trigger and the card.
renderComponentPropsWithoutRef<typeof BasePreviewCard.Trigger>["render"]–The trigger element, for example render={<Link href="/notes" />}. Its click stays its own: the card does not open or close on click. Without it, children go in a plain button.
childrenReactNode–
classNamestring–Class on the trigger, for layout.