# 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.

`<slab-hover-card>` · Overlays · built on `[popover]`

## Install

```sh
pnpm add slab-elements lit
```

## Usage

### React

`MyHoverCard.jsx`. React 19 or later, which sets props on custom elements as properties.

```jsx
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>
  );
}
```

### Lit

`my-hover-card.ts`. Slab is built on Lit, so its elements are at home in Lit's templates.

```ts
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>
    `;
  }
}
```

### Vue

`MyHoverCard.vue`. Let Vue's compiler know slab- tags are custom elements with isCustomElement.

```vue
<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>
```

### Svelte

`MyHoverCard.svelte`. Svelte binds properties and events to custom elements like any other element.

```svelte
<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>
```

### HTML

`index.html`. Any bundler (or an import map) takes care of the imports.

```html
<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>
```

## 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.

```html
<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>
```

Swap `slab-styled-` for `slab-` in that markup and you've got the same example as it ships: the headless elements with no CSS at all. That's your starting point.

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>`

#### Attributes

| 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` |  |  |

#### Events

| Name | Type | Description |
| --- | --- | --- |
| `toggle` | `Event` |  |

#### Slots

| 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 |

#### CSS parts

| 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.

### Lit

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.

#### TypeScript

These files are TypeScript, and `@customElement()` is one of Lit's decorators (TypeScript's experimental flavor). You'll need two options in your tsconfig, per [Lit's docs](https://lit.dev/docs/components/decorators/#decorators-typescript): `experimentalDecorators` to compile them, and `useDefineForClassFields` turned off so class fields don't stomp on the accessors Lit sets up.

```json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}
```

Using plain JavaScript? Drop the types and register the class with `customElements.define()` instead of the decorator.

#### hover-card.ts

```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

```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

```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);
  }
`;
```

### React, Vue and Svelte

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.

In React and Vue, each component registers its headless element and imports its CSS. Use it like the element: its props are the element's attributes and properties.

In Svelte, 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.

#### HoverCard.jsx

```jsx
import 'slab-elements/hover-card.js';
import './hover-card.css';

export function HoverCard(props) {
  return <slab-hover-card {...props} />;
}
```

#### HoverCard.vue

```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.js

```js
import 'slab-elements/hover-card.js';
import './hover-card.css';
```

#### hover-card.css

```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

```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);
}
```
