Hover Card
Free · MITHeadless, 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
npx astrocraft-ui add overlays/hover-cardPlain-CSS theme — no build step
npx astrocraft-ui add overlays/hover-card --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add overlays/hover-card --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add overlays/hover-card --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add overlays/hover-card --theme tailwind --bridge lumosLive 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.
Platform team
Owns the design system, the build and the release train. Nine people, two time zones.
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.
| Component | Slots | Variants | Runtime state | Native 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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"span"> & {
openDelay?: number;
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;
anchorTo(panel, () => trigger.getBoundingClientRect());
panel.showPopover();
place(panel);
}, openDelay);
};
const close = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (panel.matches(":popover-open")) panel.hidePopover();
}, closeDelay);
};
for (const el of [trigger, panel]) {
el.addEventListener("pointerenter", open);
el.addEventListener("pointerleave", close);
}
trigger.addEventListener("focus", () => {
if (trigger.matches(":focus-visible")) open();
});
trigger.addEventListener("blur", close);
}
onReadyOnce('[data-slot="hover-card"]', wire);
</script>
---
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>
---
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>
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.