# Preview Card

Shows a preview of what is behind a link while the pointer rests on it.

Preview Card shows what is behind a link before anyone follows it, whether that
is a profile, an article or a repository. It opens when the pointer rests on the
link and when the link takes focus from the keyboard. It never opens on touch,
where there is no hover to read.

The example switches the portal off, so the popup renders in place inside the
sentence, which is why that sentence sits in a `<div>`: a paragraph cannot
legally hold it.

::component-preview{name="PreviewCardPreview"}
```vue
<template>
  <div class="type-surface-body-md text-surface max-w-md text-pretty">
    Mirror is built and maintained by
    <PreviewCard.Root :options="{ portal: { disabled: true } }">
      <PreviewCard.Trigger
        class="text-surface-link hover:text-surface-link-hover rounded-component-xs focus-visible:focus-ring underline decoration-1 underline-offset-4 outline-4 outline-transparent transition-all duration-100 ease-linear"
        href="https://maas.engineering"
      >
        Robin Vey
      </PreviewCard.Trigger>

      <PreviewCard.Content
        class="rounded-component-2xl bg-primary-inverted shadow-component-high w-72 p-4 backdrop-blur-[8rem] [&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear"
      >
        <div class="flex items-center gap-3">
          <Avatar.Root
            class="bg-primary-subtle text-primary-on-subtle rounded-component-lg type-component-xl flex size-12 shrink-0 items-center justify-center overflow-hidden"
          >
            <Avatar.Image src="/robin.jpg" alt="Robin Vey" class="size-full" />
            <Avatar.Fallback
              class="leading-none font-medium tracking-[0.02em]"
            >
              RV
            </Avatar.Fallback>
          </Avatar.Root>

          <div class="flex min-w-0 flex-col gap-0.5">
            <span
              class="type-component-lg text-primary-on-subtle truncate leading-none font-medium"
            >
              Robin Vey
            </span>
            <span class="type-component-2xs text-primary-muted truncate">
              @robinscholz
            </span>
          </div>
        </div>

        <p class="type-component-xs text-primary-muted mt-3 text-pretty">
          Design engineer in Munich, building the tools the rest of Magic as a
          Service runs on.
        </p>
      </PreviewCard.Content>
    </PreviewCard.Root>
    and the team behind Magic as a Service.
  </div>
</template>

<script setup lang="ts">
import { Avatar, PreviewCard } from '@maas/mirror/vue'
</script>
```
::

## Usage guidelines

- Put everything the card shows behind the link as well. The card is never
  announced to a screen reader and never reachable on touch, so nothing in it
  can be the only way to that information.
- Keep the trigger a link. If the preview hangs off a button, use
  [`Popover`](/components/popover); if it only shows a label, use
  [`Tooltip`](/components/tooltip).
- Leave `delay` where it is unless you have a reason. Six hundred milliseconds
  is long enough that a pointer crossing the link on its way somewhere else does
  not open anything.
- `PreviewCard.Content` teleports to the end of `<body>` unless you turn that
  off with `options.portal.disabled`. Leave it on where the link sits inside
  anything with `overflow: hidden`.
- The content resolves everything but `side` and `align` once, during setup, so
  change the rest before the popup first renders or key the root to remount it.

## Anatomy

Assemble the parts. `PreviewCard.Content` is one part over three boxes: the
teleport, the floating box and the popup, plus a backdrop under
`options.backdrop`. Everything you put inside it renders in the popup.

::component-anatomy
---
parts:
  - name: PreviewCard.Root
    required: true
    description: 'Renders nothing of its own. It is the provider.'
    children:
      - name: PreviewCard.Trigger
        required: true
        description: 'Renders an <a>. The card hangs off it and is positioned against it.'
      - name: PreviewCard.Content
        required: true
        description: 'Renders the teleport, the floating box and the popup.'
        children:
          - name: PreviewCard.Arrow
            description: 'Optional. Renders a <div> pointing at the trigger.'
---
::

```vue
<script setup lang="ts">
import { PreviewCard } from '@maas/mirror/vue'
</script>

<template>
  <PreviewCard.Root>
    <PreviewCard.Trigger href="/people/robin">Robin Vey</PreviewCard.Trigger>
    <PreviewCard.Content>
      <PreviewCard.Arrow />
      Design engineer in Munich.
    </PreviewCard.Content>
  </PreviewCard.Root>
</template>
```

## Examples

### Timing

`delay` is how long the pointer has to rest on the link before the card opens,
`closeDelay` how long it has to be away before it closes again. The close delay
is what lets the pointer travel from the link to the card without the card
disappearing under it, so shortening it much below the default makes the card
hard to reach.

```vue
<template>
  <PreviewCard.Root :options="{ delay: 300, closeDelay: 200 }">
    <PreviewCard.Trigger href="/people/robin">Robin</PreviewCard.Trigger>
  </PreviewCard.Root>
</template>
```

Both also exist as props on the trigger, for a single link that wants to behave
differently from the rest.

```vue
<template>
  <PreviewCard.Trigger :delay="0" href="/people/robin">Robin</PreviewCard.Trigger>
</template>
```

### Positioning

`PreviewCard.Content` reads `options.floating` and writes the resolved side and
alignment back as `data-side` and `data-align`; read those rather than the
options you passed, since flipping can change either one.

```vue
<template>
  <PreviewCard.Root
    :options="{
      floating: { side: 'top', align: 'center', sideOffset: 12 },
    }"
  />
</template>
```

Pass `anchor` to position against something else, and pass any of the eight
floating keys straight to the content to override the options for one instance.

```vue
<template>
  <PreviewCard.Content :anchor="() => frame" side="top" :side-offset="12" />
</template>
```

The content writes `--rack-floating-anchor-width`,
`--rack-floating-anchor-height`, `--rack-floating-available-width`,
`--rack-floating-available-height` and `--rack-floating-transform-origin` onto
the floating box, so an unstyled popup is never taller than the space around the
link.

Add a `PreviewCard.Arrow` inside the content and it is placed by the same
middleware run, against the same anchor. It writes `--rack-arrow-x` and
`--rack-arrow-y`, so its own stylesheet can point it the right way per side.

```css
.arrow[data-side='bottom'] {
  top: -6px;
  rotate: 180deg;
}
```

### Where the card renders

The content teleports to the end of `<body>`, which is what keeps a card out of
an ancestor’s `overflow: hidden` and above the rest of the page. Turn it off to
render in place, or name a target of your own.

```vue
<template>
  <PreviewCard.Root :options="{ portal: { to: '#overlays' } }" />
  <PreviewCard.Root :options="{ portal: { disabled: true } }" />
</template>
```

`options.backdrop` adds a fixed `<div role="presentation">` behind the card. It
takes no pointer events by default, since the pointer has to travel from the
link to the popup, and it has no dismiss behaviour of its own: the popup’s layer
already closes on a pointer down outside.

```vue
<template>
  <PreviewCard.Root :options="{ backdrop: true }" />
</template>
```

### Animating it

Name a transition and the popup runs it on the way in and out. The backdrop
takes its own.

```vue
<template>
  <PreviewCard.Root
    :options="{ transition: { popup: 'card', backdrop: 'card-backdrop' } }"
  />
</template>
```

```css
.card-enter-active,
.card-leave-active {
  transition: opacity 150ms ease-out;
}

.card-enter-from,
.card-leave-to {
  opacity: 0;
}
```

Without a name the popup unmounts as it closes. For a keyframe animation rather
than a transition, read `data-state`, which is `open` or `closed` for as long as
the popup is rendered, and the popup waits for the animation to end before it
goes.

```css
.popup[data-state='closed'] {
  animation: fade-out 150ms ease-in;
}
```

[Animation](/components/animation) has both patterns in full.

### Reaching the card from anywhere

Give the root an `id` and `useMirrorPreviewCard(id)` opens, closes and reads that
card from anywhere in the app.

```vue
<script setup lang="ts">
const { isOpen, open, close } = useMirrorPreviewCard(PreviewCardId.Robin)
</script>

<template>
  <Button @click="open()">Show the preview</Button>
</template>
```

### Styling from state

Every part writes its state to `data-*`, so style it with attribute selectors.

```css
.trigger[data-popup-open='true'] {
  text-decoration-style: dotted;
}

.popup[data-side='top'] {
  transform-origin: bottom center;
}
```

The content’s boxes are not parts, so they are reached by their `data-scope` or
by the class each one ships. A `class` on `PreviewCard.Content` is applied to
the popup, the box you paint.

```vue
<template>
  <PreviewCard.Content class="popup" />
</template>
```

```css
.mirror-preview-card-floating {
  --mirror-preview-card-floating-z-index: 80;
}

[data-scope='floating'][data-anchor-hidden='true'] {
  visibility: hidden;
}
```

## API reference

**Module.** A bundled `options` object on the root, and
`useMirrorPreviewCard(id)` as the programmatic API. Every part takes `id` to
resolve a card it is not nested inside.

### `PreviewCard.Root`

Renders its children. It is a provider and has no element of its own.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: '[The instance ID](#reaching-the-card-from-anywhere).'
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: open
        description: Open state. `v-model:open`.
      - label: 'boolean | undefined'
      - label: undefined
  - items:
      - label: defaultOpen
        description: 'Initial open state when [uncontrolled](/components/composition#controlled-and-uncontrolled).'
      - label: boolean
      - label: 'false'
  - items:
      - label: options
        description: Everything else. Deep-merged over the defaults.
      - label: PreviewCardOptions
      - label: see below
        plaintext: true
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: delay
        description: 'Milliseconds the pointer rests on the trigger before it [opens](#timing).'
      - label: number
      - label: '600'
  - items:
      - label: closeDelay
        description: 'Milliseconds the pointer is away before it [closes](#timing).'
      - label: number
      - label: '300'
  - items:
      - label: disabled
        description: Refuses to open, by pointer and by keyboard alike.
      - label: boolean
      - label: 'false'
  - items:
      - label: forceMount
        description: Keeps the popup and the backdrop mounted while closed.
      - label: boolean
      - label: 'false'
  - items:
      - label: backdrop
        description: Renders a fixed `<div role="presentation">` behind the card.
      - label: boolean
      - label: 'false'
  - items:
      - label: dismiss.escape
        description: Escape closes the card.
      - label: boolean
      - label: 'true'
  - items:
      - label: dismiss.pointerDownOutside
        description: A pointer down outside closes the card.
      - label: boolean
      - label: 'true'
  - items:
      - label: dismiss.focusOutside
        description: Focus leaving closes the card.
      - label: boolean
      - label: 'true'
  - items:
      - label: portal.to
        description: The teleport target.
      - label: 'string | HTMLElement'
      - label: body
        plaintext: true
  - items:
      - label: portal.disabled
        description: Renders in place instead.
      - label: boolean
      - label: 'false'
  - items:
      - label: portal.defer
        description: Resolves the target after mount, for a target rendered by the same app.
      - label: boolean
      - label: 'false'
  - items:
      - label: floating.side
        description: Preferred side. Reactive.
      - label: '''top'' | ''right'' | ''bottom'' | ''left'''
      - label: '''bottom'''
  - items:
      - label: floating.sideOffset
        description: Gap from the trigger, in pixels.
      - label: number
      - label: '8'
  - items:
      - label: floating.align
        description: Alignment along that side. Reactive.
      - label: '''start'' | ''center'' | ''end'''
      - label: '''center'''
  - items:
      - label: floating.alignOffset
        description: Shift along the alignment axis, in pixels.
      - label: number
      - label: '0'
  - items:
      - label: floating.strategy
        description: Positioning strategy.
      - label: '''absolute'' | ''fixed'''
      - label: '''absolute'''
  - items:
      - label: floating.flip
        description: Flip to the opposite side when there is no room.
      - label: boolean
      - label: 'true'
  - items:
      - label: floating.shift
        description: Slide along the side to stay in view.
      - label: boolean
      - label: 'true'
  - items:
      - label: floating.collisionPadding
        description: Padding used by flipping and shifting.
      - label: number
      - label: '8'
  - items:
      - label: transition.popup
        description: Vue transition name for the popup.
      - label: string
      - label: undefined
  - items:
      - label: transition.backdrop
        description: Vue transition name for the backdrop.
      - label: string
      - label: undefined
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: update:open
        description: The card opens or closes.
      - label: boolean
  - items:
      - label: openChange
        description: The card opens or closes, with what caused it.
      - label: '(value: boolean, reason: PreviewCardOpenReason)'
---
::

`PreviewCardOpenReason` is `trigger-hover`, `trigger-focus`, `trigger-press`,
`outside-press`, `escape-key`, `imperative-action` or `none`.

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: open
        description: The card is open.
      - label: boolean
  - items:
      - label: side
        description: Mirrors `data-side`.
      - label: 'PreviewCardSide | undefined'
  - items:
      - label: align
        description: Mirrors `data-align`.
      - label: 'PreviewCardAlign | undefined'
---
::

### `PreviewCard.Trigger`

Renders an `<a>`. It is the anchor the card is positioned against, and it stays
an ordinary link: pressing it navigates and closes the card.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: delay
        description: Escape hatch over `options.delay`.
      - label: number
      - label: options.delay
  - items:
      - label: closeDelay
        description: Escape hatch over `options.closeDelay`.
      - label: number
      - label: options.closeDelay
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: open
        description: Mirrors `data-popup-open`.
      - label: boolean
  - items:
      - label: side
        description: Mirrors `data-side`.
      - label: 'PreviewCardSide | undefined'
  - items:
      - label: align
        description: Mirrors `data-align`.
      - label: 'PreviewCardAlign | undefined'
---
::

### `PreviewCard.Content`

Renders everything that floats: the teleport, the floating box and the popup,
plus a backdrop under `options.backdrop`. Everything in its default slot renders
inside the popup. It anchors on `PreviewCard.Trigger`, and it keeps the card
open while the pointer is over the popup. It never takes focus.

#### Layers

None of the boxes is a part, so each is styled through its `data-scope` or the
class it ships. A `class` on the content itself is applied to the popup.

::docs-table
---
columns:
  - label: Layer
  - label: Element
  - label: Publishes
rows:
  - items:
      - label: portal
        description: The teleport. Configured through `options.portal`.
      - label: none
        plaintext: true
      - label: nothing
        plaintext: true
  - items:
      - label: backdrop
        description: '`.mirror-preview-card-backdrop`, only under `options.backdrop`.'
      - label: 'div[role=presentation]'
      - label: '`data-preview-card`, `data-state`'
        plaintext: true
  - items:
      - label: floating
        description: '`.mirror-preview-card-floating`. Carries the floating styles and the `--rack-floating-*` set.'
      - label: div
      - label: '`data-side`, `data-align`, `data-anchor-hidden`, `data-state`'
        plaintext: true
  - items:
      - label: popup
        description: '`.mirror-preview-card-popup`. Presence and dismissal live here, and the pointer resting on it keeps the card open.'
      - label: div
      - label: '`data-preview-card`, `data-side`, `data-align`, `data-state`'
        plaintext: true
---
::

#### Props

Every prop below but `forceMount` and `anchor` is an escape hatch over the
matching `options.floating` key, and only `side` and `align` stay reactive
after mount. `forceMount` is the hatch over `options.forceMount`.

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: forceMount
        description: Keeps the popup and the backdrop mounted while closed.
      - label: boolean
      - label: options.forceMount
  - items:
      - label: anchor
        description: Position against this element instead.
      - label: 'HTMLElement | (() => HTMLElement | null)'
      - label: the trigger
        plaintext: true
  - items:
      - label: side
        description: Preferred side.
      - label: '''top'' | ''right'' | ''bottom'' | ''left'''
      - label: '''bottom'''
  - items:
      - label: sideOffset
        description: Gap from the trigger, in pixels.
      - label: number
      - label: '8'
  - items:
      - label: align
        description: Alignment along that side.
      - label: '''start'' | ''center'' | ''end'''
      - label: '''center'''
  - items:
      - label: alignOffset
        description: Shift along the alignment axis, in pixels.
      - label: number
      - label: '0'
  - items:
      - label: strategy
        description: Positioning strategy.
      - label: '''absolute'' | ''fixed'''
      - label: '''absolute'''
  - items:
      - label: flip
        description: Flip to the opposite side when there is no room.
      - label: boolean
      - label: 'true'
  - items:
      - label: shift
        description: Slide along the side to stay in view.
      - label: boolean
      - label: 'true'
  - items:
      - label: collisionPadding
        description: Padding used by flipping and shifting.
      - label: number
      - label: '8'
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: open
        description: Mirrors `data-state`.
      - label: boolean
  - items:
      - label: side
        description: Resolved after collision handling.
      - label: PreviewCardSide
  - items:
      - label: align
        description: Resolved after collision handling.
      - label: PreviewCardAlign
---
::

### `PreviewCard.Arrow`

Renders a `<div aria-hidden="true">` positioned against the trigger, with
`--rack-arrow-x` and `--rack-arrow-y` on it. It must be nested inside a
`PreviewCard.Content`, and its slot takes `side` and `align`.

### Composable

`useMirrorPreviewCard(id)` reaches a card from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: isOpen
        description: '`true` while the card is open.'
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: reason
        description: What opened or closed the card last.
      - label: ComputedRef<PreviewCardOpenReason>
        escape: true
  - items:
      - label: side
        description: The resolved side.
      - label: 'ComputedRef<PreviewCardSide | undefined>'
        escape: true
  - items:
      - label: align
        description: The resolved alignment.
      - label: 'ComputedRef<PreviewCardAlign | undefined>'
        escape: true
  - items:
      - label: open
        description: Opens the card.
      - label: '(reason?: PreviewCardOpenReason) => void'
        escape: true
  - items:
      - label: close
        description: Closes the card.
      - label: '(reason?: PreviewCardOpenReason) => void'
        escape: true
  - items:
      - label: toggle
        description: Opens or closes.
      - label: '(reason?: PreviewCardOpenReason) => void'
        escape: true
---
::

### Data attributes

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_preview_card_context
        description: A `PreviewCard` part rendered outside `PreviewCard.Root` and received no `id`.
  - items:
      - label: missing_preview_card_content
        description: A `PreviewCard.Arrow` rendered outside a `PreviewCard.Content`.
---
::

## Accessibility

The trigger is a plain link and the card carries no ARIA. The card duplicates
what is behind the link, it opens on hover and on focus without being asked, and
it takes no focus, so wiring it up as `aria-describedby` would read the whole
preview out every time a screen-reader user reached the link. Put everything the
card shows behind the link as well, and nothing is lost by leaving the card
unannounced.

::docs-table
---
columns:
  - label: Key
  - label: Behaviour
rows:
  - items:
      - label: Tab on to the trigger
        plaintext: true
      - label: Opens the card straight away, with no delay.
        plaintext: true
  - items:
      - label: Tab off the trigger
        plaintext: true
      - label: Closes the card, unless focus moved into the popup.
        plaintext: true
  - items:
      - label: Escape
      - label: Closes the card. Focus never moved, so it stays on the trigger.
        plaintext: true
  - items:
      - label: Enter on the trigger
        plaintext: true
      - label: Follows the link, as on any other link.
        plaintext: true
---
::

A touch never opens the card. There is no hover on a touch screen to read intent
from, and a card that opened on tap would swallow the tap that was meant to
follow the link.
