Skip to main content
astrocraft-ui/ components · 101

Hover Card

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add overlays/hover-card

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add overlays/hover-card --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add overlays/hover-card --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add overlays/hover-card --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add overlays/hover-card --theme tailwind --bridge lumos

Live demo

HoverCard

Rest on the link and wait — it does not fire on a pass-by. Then move the pointer INTO the card: the close delay is what lets you get there. Tab to it instead and it opens on focus, because hover is not an interaction everyone has.

Maintained by the platform team

Platform team

Owns the design system, the build and the release train. Nine people, two time zones.

, who review every change.

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`HoverCard.astro``hover-card`———
`HoverCardContent.astro``hover-card-content``data-side`: `bottom` · `top` · `left` · `right`
`data-align`: `start` · `center` · `end`
——
`HoverCardTrigger.astro``hover-card-trigger`———

Source

What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/overlays/hover-card/HoverCard.astro
---
// src/components/ui/overlays/hover-card/HoverCard.astro — headless primitive (see ../../README.md).
// The preview card that appears when you rest on a link. Compose HoverCardTrigger / HoverCardContent.
//
// THE DELAYS ARE THE COMPONENT. Without an open delay the card fires at every link the pointer
// crosses on its way somewhere else; without a close delay it vanishes the instant the pointer
// leaves the trigger, which is exactly when the pointer is travelling INTO the card. Both are the
// difference between a hover card that is usable with a trackpad and one that is not, and neither
// can be expressed in CSS, which is why this primitive ships a script and Tooltip does not.
//
// Keyboard and screen-reader users are not served by hover at all, so they get a different, better
// contract: the trigger is `aria-describedby` the content, so the card is ANNOUNCED on focus rather
// than shown on a timer. The card still opens on keyboard focus (`:focus-visible` only — a mouse
// click on a link focuses it too, and that should not count as intent), and Escape closes it
// natively. A hover card must therefore never hold anything not reachable another way.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"span"> & {
  /** Rest-before-open, in ms. Long enough to not fire while the pointer is merely passing. */
  openDelay?: number;
  /** Grace period before closing, in ms. Must cover the trip from the trigger into the card. */
  closeDelay?: number;
};

const { openDelay = 600, closeDelay = 300, class: className, ...rest } = Astro.props;
---

<span
  class={className}
  data-slot="hover-card"
  data-open-delay={openDelay}
  data-close-delay={closeDelay}
  {...rest}
>
  <slot />
</span>

<script>
  import { anchorTo, place } from "../../_anchor";
  import { onReadyOnce } from "../../_once";

  function wire(root: HTMLElement) {
    const trigger = root.querySelector<HTMLElement>('[data-slot="hover-card-trigger"]');
    const panel = root.querySelector<HTMLElement>('[data-slot="hover-card-content"]');
    if (!trigger || !panel) return;

    const openDelay = Number(root.dataset.openDelay);
    const closeDelay = Number(root.dataset.closeDelay);
    let timer: ReturnType<typeof setTimeout> | undefined;

    const open = () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        if (panel.matches(":popover-open")) return;
        // There is no `popovertarget` here — the card is opened by intent, not by a click — so the
        // controller is told what to anchor to. It forgets on close, hence re-stating it each time.
        anchorTo(panel, () => trigger.getBoundingClientRect());
        panel.showPopover();
        place(panel);
      }, openDelay);
    };
    const close = () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        if (panel.matches(":popover-open")) panel.hidePopover();
      }, closeDelay);
    };

    // The panel gets the same pair so the pointer can live inside the card without it closing.
    for (const el of [trigger, panel]) {
      el.addEventListener("pointerenter", open);
      el.addEventListener("pointerleave", close);
    }
    // `:focus-visible` is the whole guard: clicking a link focuses it, and a click is not a request
    // for a preview of where you are already going.
    trigger.addEventListener("focus", () => {
      if (trigger.matches(":focus-visible")) open();
    });
    trigger.addEventListener("blur", close);
  }

  onReadyOnce('[data-slot="hover-card"]', wire);
</script>
src/components/ui/overlays/hover-card/HoverCardContent.astro
---
// src/components/ui/overlays/hover-card/HoverCardContent.astro — HoverCard compound part (see ../../README.md).
// Top-layer preview panel. `popover="auto"` for native Escape and light dismiss; `data-anchor` opts
// it into the shared `_anchor` controller, which the HoverCard script points at the trigger (there
// is no `popovertarget` — the card is opened by pointer intent, not by activation).
//
// It is NOT `role="dialog"`: nothing here should be reachable only by hovering, so announcing it as
// a dialog would promise an interaction model the component deliberately does not have. The trigger
// describes itself with this content instead — see HoverCardTrigger.
import "../../../../styles/structure.css";

import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  id: string;
  side?: "bottom" | "top" | "left" | "right";
  align?: "start" | "center" | "end";
  offset?: number;
};

const { side = "bottom", align = "center", offset, class: className, ...rest } = Astro.props;
---

<div
  popover="auto"
  data-slot="hover-card-content"
  data-anchor
  data-side={side}
  data-align={align}
  data-offset={offset}
  class={className}
  {...rest}
>
  <slot />
</div>
src/components/ui/overlays/hover-card/HoverCardTrigger.astro
---
// src/components/ui/overlays/hover-card/HoverCardTrigger.astro — HoverCard compound part (see ../../README.md).
// Renders an <a> when `href` is set (the usual case — a hover card previews a destination), a
// <button> otherwise. `for` must be the HoverCardContent's `id`: it becomes `aria-describedby`, so
// the card's content is announced to a screen-reader user on focus, who will never trigger a hover.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"button"> & { for: string; href?: string };

const { for: target, href, class: className, ...rest } = Astro.props;
const Tag = href ? "a" : "button";
---

<Tag
  href={href}
  type={href ? undefined : "button"}
  aria-describedby={target}
  data-slot="hover-card-trigger"
  class={className}
  {...rest}
>
  <slot />
</Tag>
src/components/ui/overlays/hover-card/index.ts
import HoverCard from "./HoverCard.astro";
import HoverCardContent from "./HoverCardContent.astro";
import HoverCardTrigger from "./HoverCardTrigger.astro";

export { HoverCard, HoverCardContent, HoverCardTrigger };
export default HoverCard;

What you get

The component source, copied into your project by npx astrocraft-ui add overlays/hover-card — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.