hovercard
Rest on the link. The card is pinned open here so you can read it.
import { hovercard } from '@markless/ui';
const link = 'color: var(--ink); font-weight: 700; text-decoration: underline;';
const card =
'display: grid; gap: 0.35em; width: min(18rem, 100%); margin-block-start: 0.45em; padding: 0.8em; border: 2px solid var(--ink); border-radius: 6px; background: var(--raised); box-shadow: 3px 3px 0 var(--ink); font-size: var(--step--1); text-align: left;';
export default function Mention() @{
<hovercard.root open={true} style="display: grid; justify-items: center;">
<hovercard.trigger href="/markless/ui/accordion" style={link}>the accordion page</hovercard.trigger>
<hovercard.content style={card}>
<strong>accordion</strong>
<span>Sections named by value, with the closed panel left in the page.</span>
<a href="/markless/ui/accordion" style={link}>Read it</a>
</hovercard.content>
</hovercard.root>
}
A hover card previews where a link goes. Unlike a tooltip's tip this is a surface a person can work in, so links, buttons and images all belong in it. That freedom comes with one rule: never put anything in the card that is not also behind the link. Someone on a touch screen, or reading with a virtual cursor, never sees the card at all. They follow the link, and they have to get everything.
Anatomy
hovercard.rootholds whether the card is showing, and both waits.hovercard.triggeris the link the card previews. It renders an<a>and only an<a>.hovercard.contentis the card, written after the trigger.
It writes ui-open and ui-closed as its state changes.
Let the pointer cross the gap
A card usually sits at a visible offset from its link, so the pointer crosses dead space
belonging to neither. closeDelay is how long the card waits after the pointer leaves,
300 milliseconds by default, and it is what carries the pointer over that gap.
<hovercard.root delay={400} closeDelay={500}>
Move the card
The card lands below the link. That default ships inside @layer markless, so one
unlayered rule of yours replaces it, and where it goes after that is your CSS.
.mention-card {
position-area: block-start;
}
Nothing exclusive to the card
The card is a shortcut, so everything in it points somewhere the link already reaches. Write it after the trigger too: a CSS anchor has to be laid out before the element pointing at it.
<hovercard.trigger href="/people/ada">Ada</hovercard.trigger>
<hovercard.content>
<a href="/people/ada">Ada Lovelace</a>
<p>Writes about analytical engines.</p>
</hovercard.content>
Keyboard
Every handler sits on the root, which wraps both parts, so moving from the link into the card, by pointer or by Tab, never counts as leaving.
| Key | What it does |
|---|---|
| Tab | Focusing the link waits the same delay the pointer does, then shows the card. |
| Tab again | Moves into the card, which is the link's next sibling and back in the tab order once shown. |
| Escape | Hides the card. Focus goes back to the link only if it was inside the card. |
API
Every prop is optional, and the types below are the family's own.
hovercard.root
| Prop | Type | What it does |
|---|---|---|
open |
boolean |
Whether the card is showing. Omit it and it starts hidden. |
delay |
number |
How long the pointer must rest on the link, or focus must stay on it, before the card shows, in milliseconds. Omit it and it is 700. |
closeDelay |
number |
How long the card stays after the pointer leaves, in milliseconds. Omit it and it is 300. |
onChange |
(open: boolean) => void |
Called with the new value when the card shows or hides, including when Escape or a press elsewhere hides it. |
| Attribute | Present when | What to key styling off |
|---|---|---|
ui-open |
The card is showing | The shown look. |
ui-closed |
The card is not showing | The resting look. |
Everything a <div> accepts reaches the element too, apart from onChange, which is
the family's. Your own onPointerover, onPointerout, onFocusin and onFocusout run
after the family's.
| Selector | Default | Why |
|---|---|---|
div:not([overlay]) |
anchor-scope: --ui-hovercard |
Keeps the anchor name inside this card, so two hover cards on a page never point at each other's link. |
hovercard.trigger
No props of its own. It renders an <a>, and everything an <a> accepts reaches the
element, href included.
| Attribute | Present when | What to key styling off |
|---|---|---|
aria-expanded |
Always | true or false, so a reader is told the card exists. |
aria-controls |
Always | The id of the card this link previews. |
ui-open |
The card is showing | The previewing look. |
ui-closed |
The card is not showing | The resting look. |
| Selector | Default | Why |
|---|---|---|
a |
anchor-name: --ui-hovercard |
The link is the anchor the card is placed against. |
hovercard.content
No props of its own. It renders a <div>, and everything a <div> accepts reaches the
element. Your own onDismiss runs after the family has hidden the card. It carries no
role and nothing writes aria-describedby at it: a card full of links flattened into
one description is a run-on string with none of them reachable.
| Attribute | Present when | What to key styling off |
|---|---|---|
overlay |
Always | The mark that elevates the card. Not a styling hook. |
hidden |
The card is not showing | What hides it, and what keeps its links out of the tab order. The card is never detached. |
ui-open |
The card is showing | The shown look. |
ui-closed |
The card is not showing | The hidden look. |
| Selector | Default | Why |
|---|---|---|
[overlay] |
--ui-anchor: --ui-hovercard; position: absolute; position-anchor: --ui-hovercard; position-area: block-end |
Places the card below the link with no script, and hands you the anchor name for anchor() geometry of your own. |
