Overlays
Hover Card
A preview of what a link leads to, shown beside it on hover and keyboard focus, with room for links and buttons of its own.
- Element
<slab-hover-card>- Built on
[popover]- As Markdown
- hover-card.md
Example
Here's a styled version, <slab-styled-hover-card>. It's not
in the package — it's an example of what you can build with the
headless element. Copy it and make it yours at the
bottom of this page. Switch to "As it ships" to see the same markup
with the headless element and no CSS at all. That's your starting
point.
Forms lead. 12 pours this month.
<div>
Forms go up Thursday with
<slab-styled-hover-card>
<a href="#ana" slot="anchor">Ana Ruiz</a>
<div class="stack">
<div>
<strong>Ana Ruiz</strong><br />
Forms lead. 12 pours this month.
</div>
<a href="#message-ana">Message Ana</a>
</div>
</slab-styled-hover-card>
and her crew.
</div>
Forms lead. 12 pours this month.
<div>
Forms go up Thursday with
<slab-hover-card>
<a href="#ana" slot="anchor">Ana Ruiz</a>
<div class="stack">
<div>
<strong>Ana Ruiz</strong><br />
Forms lead. 12 pours this month.
</div>
<a href="#message-ana">Message Ana</a>
</div>
</slab-hover-card>
and her crew.
</div>
Usage
pnpm add slab-elements lit
import 'slab-elements/hover-card.js';
export function MyHoverCard() {
return (
<slab-hover-card>
<a href="/crew/ana" slot="anchor">Ana Ruiz</a>
<div>
<p>Forms lead. 12 pours this month.</p>
<a href="/crew/ana/message">Message Ana</a>
</div>
</slab-hover-card>
);
}
MyHoverCard.jsx React 19 or later, which sets props on custom elements as properties.
import 'slab-elements/hover-card.js';
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('my-hover-card')
export class MyHoverCard extends LitElement {
render() {
return html`
<slab-hover-card>
<a href="/crew/ana" slot="anchor">Ana Ruiz</a>
<div>
<p>Forms lead. 12 pours this month.</p>
<a href="/crew/ana/message">Message Ana</a>
</div>
</slab-hover-card>
`;
}
}
my-hover-card.ts Slab is built on Lit, so its elements are at home in Lit's templates.
<script setup>
import 'slab-elements/hover-card.js';
</script>
<template>
<slab-hover-card>
<a href="/crew/ana" slot="anchor">Ana Ruiz</a>
<div>
<p>Forms lead. 12 pours this month.</p>
<a href="/crew/ana/message">Message Ana</a>
</div>
</slab-hover-card>
</template>
MyHoverCard.vue Let Vue's compiler know slab- tags are custom elements with isCustomElement.
<script>
import 'slab-elements/hover-card.js';
</script>
<slab-hover-card>
<a href="/crew/ana" slot="anchor">Ana Ruiz</a>
<div>
<p>Forms lead. 12 pours this month.</p>
<a href="/crew/ana/message">Message Ana</a>
</div>
</slab-hover-card>
MyHoverCard.svelte Svelte binds properties and events to custom elements like any other element.
<script type="module">
import 'slab-elements/hover-card.js';
</script>
<slab-hover-card>
<a href="/crew/ana" slot="anchor">Ana Ruiz</a>
<div>
<p>Forms lead. 12 pours this month.</p>
<a href="/crew/ana/message">Message Ana</a>
</div>
</slab-hover-card>
index.html Any bundler (or an import map) takes care of the imports.
Use a Tooltip for a short description of a control, and a Hover Card to preview where a link goes. Touch users never see the card, and nothing tells a screenreader user it’s there, so anything in it should also be on the page the anchor links to.
Keyboard
| Key | What it does |
|---|---|
| Tab | Focusing the anchor opens the card after open-delay. Tab again moves into the card. Leaving both closes it. |
| Escape | Closes the card. If focus is in the card, it goes back to the anchor. |
Behavior
A hover card with no visual styles: rich content, shown in the top layer beside the element slotted as its anchor, for a sighted user to preview what's behind a link. Unlike a tooltip's, its content can hold links and buttons.
It opens when the anchor is hovered, or focused by keyboard, after open-delay. It closes when the pointer leaves, after close-delay, when focus leaves both the anchor and the card, and on Escape. The pointer can cross from the anchor onto the card without it closing, and focus moves into it with Tab, because its content follows the anchor in the focus order. Escape with focus in the card gives focus back to the anchor. A touch doesn't open it: a tap on a link follows the link.
A hover card is a preview, not the only way to what's in it. Nothing about it is announced on the anchor: an aria-expanded would say a link expands when activating it navigates. So whatever the card holds should be reachable by following the anchor too.
It's placed beside its anchor with CSS anchor positioning, as Popover and Tooltip are, and flipped when it would leave the screen. placement picks the side and nothing else: the gap, the alignment, and every other visual choice are left to ::part(card). Where anchor positioning isn't supported, the card falls back to the browser's centering.
Style it through its card part and :state(open).
API
<slab-hover-card>
| Name | Type | Default | Description |
|---|---|---|---|
close-delay
|
number |
300 |
|
disabled
|
boolean |
false |
The card doesn't open. |
open-delay
|
number |
700 |
|
open
|
boolean |
false |
|
placement
|
'bottom' | 'left' | 'right' | 'top' |
'bottom' |
The side of its anchor it opens on, when there's room. It flips when there isn't. |
version
|
string |
| Name | Type | Description |
|---|---|---|
toggle
|
Event |
| Name | Type | Description |
|---|---|---|
anchor
|
Element |
The element the card is for, usually a link. It should be one that can be focused. |
(default)
|
Element |
The card's content, which can hold links, buttons and anything else that can be focused |
| Name | Description |
|---|---|
card
|
Copy and own it
<slab-styled-hover-card> isn't in the package. It's an example —
copy it into your project and change whatever you want. Keyboard,
focus, ARIA, forms, and events all come from the headless element,
and the styles only reach in through ::part() and
:state(), same as yours would.
The same styles, written from outside the element, so
there's nothing to subclass. The CSS styles every
<slab-hover-card> on the page. Import
tokens.css, the colors and fonts every styled
example shares, once, wherever your global styles go.
Each component registers its headless element and imports its CSS. Use it like the element: its props are the element's attributes and properties.
HoverCard.jsx
import 'slab-elements/hover-card.js';
import './hover-card.css';
export function HoverCard(props) {
return <slab-hover-card {...props} />;
}
hover-card.css
slab-hover-card::part(card) {
animation: slab-hover-card-drop 150ms ease-out;
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
color: var(--ink);
font-family: var(--font-body);
line-height: 1.5;
margin: 0.5rem;
max-inline-size: 20rem;
padding: 1rem 1.25rem;
}
@keyframes slab-hover-card-drop {
from {
opacity: 0;
translate: 0 -0.375rem;
}
}
@media (prefers-reduced-motion: reduce) {
slab-hover-card::part(card) {
animation: none;
}
}
tokens.css
:root {
/* Secondary text. 6.8:1 and 6.9:1 on paper. */
--ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
--border: 2px solid var(--ink);
/* Behind disabled and read-only controls, and hovered rows. */
--chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));
/*
The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
enough for borders, fills and large text, but not always for small text,
which takes "--ember-deep".
*/
--ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));
/*
Orange for small text on paper, and for paper text on it. 5.7:1 and
7.4:1.
*/
--ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
--font-body: var(
--slab-styled-font-body,
'IBM Plex Sans',
system-ui,
sans-serif
);
--font-display: var(
--slab-styled-font-display,
'Zilla Slab',
rockwell,
'Roboto Slab',
serif
);
--font-mono: var(
--slab-styled-font-mono,
'IBM Plex Mono',
ui-monospace,
monospace
);
--ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
--paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));
/* The hard, offset shadow a raised surface casts. */
--shadow: 4px 4px 0 var(--ink);
}
Under the hood, it's just the headless element, subclassed,
with styles set. That's it. To use it, copy
these files, swap the tag in @customElement()
for one of your own, and import it.
tokens.styles.ts holds the colors and fonts
every styled example shares, so you only need it once.
hover-card.ts
import { customElement } from 'lit/decorators.js';
import HoverCard from 'slab-elements/hover-card.js';
import styles from './hover-card.styles.js';
import tokens from './tokens.styles.js';
declare global {
interface HTMLElementTagNameMap {
'slab-styled-hover-card': StyledHoverCard;
}
}
/**
* Hover Card, styled: a slip of paper with an ink border and an ember shadow,
* as Popover is.
*/
@customElement('slab-styled-hover-card')
export default class StyledHoverCard extends HoverCard {
static override styles = [tokens, styles];
}
hover-card.styles.ts
import { css } from 'lit';
/* A closed card isn't displayed, so the entrance runs each time it opens. */
export default css`
[part~='card'] {
animation: drop 150ms ease-out;
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
color: var(--ink);
font-family: var(--font-body);
line-height: 1.5;
margin: 0.5rem;
max-inline-size: 20rem;
padding: 1rem 1.25rem;
}
@keyframes drop {
from {
opacity: 0;
translate: 0 -0.375rem;
}
}
@media (prefers-reduced-motion: reduce) {
[part~='card'] {
animation: none;
}
}
`;
tokens.styles.ts
import { css } from 'lit';
/*
The styled components' palette and type, taken from the logo: ink for the
courses, ember for the base, paper behind them.
Each is a short alias for a "--slab-styled-*" property, with the default as
its fallback. Retheming is "--slab-styled-ember" and the rest on ":root",
which every shadow root inherits. Were the defaults declared under those
names here instead, each host's own declaration would shadow the page's.
Each default is "light-dark()", so the components follow the page's
"color-scheme": "dark" for dark, "light" for light, and "light dark" to
follow the operating system. Ink and paper trade places in the dark, so
ink is always the text and paper always what's behind it. Contrast is given
as light, then dark.
*/
export default css`
:host {
/* Secondary text. 6.8:1 and 6.9:1 on paper. */
--ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
--border: 2px solid var(--ink);
/* Behind disabled and read-only controls, and hovered rows. */
--chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));
/*
The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
enough for borders, fills and large text, but not always for small text,
which takes "--ember-deep".
*/
--ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));
/*
Orange for small text on paper, and for paper text on it. 5.7:1 and
7.4:1.
*/
--ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
--font-body: var(
--slab-styled-font-body,
'IBM Plex Sans',
system-ui,
sans-serif
);
--font-display: var(
--slab-styled-font-display,
'Zilla Slab',
rockwell,
'Roboto Slab',
serif
);
--font-mono: var(
--slab-styled-font-mono,
'IBM Plex Mono',
ui-monospace,
monospace
);
--ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
--paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));
/* The hard, offset shadow a raised surface casts. */
--shadow: 4px 4px 0 var(--ink);
}
`;
The same styles, written from outside the element, so
there's nothing to subclass. The CSS styles every
<slab-hover-card> on the page. Import
tokens.css, the colors and fonts every styled
example shares, once, wherever your global styles go.
Each component registers its headless element and imports its CSS. Use it like the element: its props are the element's attributes and properties.
HoverCard.vue
<script setup>
import 'slab-elements/hover-card.js';
import './hover-card.css';
</script>
<template>
<slab-hover-card><slot /></slab-hover-card>
</template>
hover-card.css
slab-hover-card::part(card) {
animation: slab-hover-card-drop 150ms ease-out;
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
color: var(--ink);
font-family: var(--font-body);
line-height: 1.5;
margin: 0.5rem;
max-inline-size: 20rem;
padding: 1rem 1.25rem;
}
@keyframes slab-hover-card-drop {
from {
opacity: 0;
translate: 0 -0.375rem;
}
}
@media (prefers-reduced-motion: reduce) {
slab-hover-card::part(card) {
animation: none;
}
}
tokens.css
:root {
/* Secondary text. 6.8:1 and 6.9:1 on paper. */
--ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
--border: 2px solid var(--ink);
/* Behind disabled and read-only controls, and hovered rows. */
--chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));
/*
The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
enough for borders, fills and large text, but not always for small text,
which takes "--ember-deep".
*/
--ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));
/*
Orange for small text on paper, and for paper text on it. 5.7:1 and
7.4:1.
*/
--ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
--font-body: var(
--slab-styled-font-body,
'IBM Plex Sans',
system-ui,
sans-serif
);
--font-display: var(
--slab-styled-font-display,
'Zilla Slab',
rockwell,
'Roboto Slab',
serif
);
--font-mono: var(
--slab-styled-font-mono,
'IBM Plex Mono',
ui-monospace,
monospace
);
--ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
--paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));
/* The hard, offset shadow a raised surface casts. */
--shadow: 4px 4px 0 var(--ink);
}
The same styles, written from outside the element, so
there's nothing to subclass. The CSS styles every
<slab-hover-card> on the page. Import
tokens.css, the colors and fonts every styled
example shares, once, wherever your global styles go.
Import hover-card.js once, such
as in your root layout, and use
<slab-hover-card> as is. A Svelte component
around it would take slot="…" content as its
own slot and drop it.
hover-card.js
import 'slab-elements/hover-card.js';
import './hover-card.css';
hover-card.css
slab-hover-card::part(card) {
animation: slab-hover-card-drop 150ms ease-out;
background-color: var(--paper);
border: var(--border);
box-shadow: var(--shadow);
color: var(--ink);
font-family: var(--font-body);
line-height: 1.5;
margin: 0.5rem;
max-inline-size: 20rem;
padding: 1rem 1.25rem;
}
@keyframes slab-hover-card-drop {
from {
opacity: 0;
translate: 0 -0.375rem;
}
}
@media (prefers-reduced-motion: reduce) {
slab-hover-card::part(card) {
animation: none;
}
}
tokens.css
:root {
/* Secondary text. 6.8:1 and 6.9:1 on paper. */
--ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
--border: 2px solid var(--ink);
/* Behind disabled and read-only controls, and hovered rows. */
--chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));
/*
The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
enough for borders, fills and large text, but not always for small text,
which takes "--ember-deep".
*/
--ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));
/*
Orange for small text on paper, and for paper text on it. 5.7:1 and
7.4:1.
*/
--ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
--font-body: var(
--slab-styled-font-body,
'IBM Plex Sans',
system-ui,
sans-serif
);
--font-display: var(
--slab-styled-font-display,
'Zilla Slab',
rockwell,
'Roboto Slab',
serif
);
--font-mono: var(
--slab-styled-font-mono,
'IBM Plex Mono',
ui-monospace,
monospace
);
--ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
--paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));
/* The hard, offset shadow a raised surface casts. */
--shadow: 4px 4px 0 var(--ink);
}