# 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 teleport 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="{ teleport: { 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 } from '@maas/mirror/vue/Avatar'
import { PreviewCard } from '@maas/mirror/vue/PreviewCard'
</script>
```
::

## Usage guidelines

::usage-guidelines

## Anatomy

Assemble the parts. `PreviewCard.Content` is one part over three boxes: the
teleport, the floating box and the popup. 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.Backdrop
        description: 'Optional. Renders a <div> behind the card.'
      - 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/PreviewCard'
</script>

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

## Examples

### Timing

`delay.mouseenter` is how long the pointer has to rest on the link before the
card opens, `delay.mouseleave` how long it has to be away before it closes
again. The leave 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: { mouseenter: 300, mouseleave: 200 } }">
    <PreviewCard.Trigger href="/people/robin">Robin</PreviewCard.Trigger>
  </PreviewCard.Root>
</template>
```

The trigger takes a `delay` of the same shape, for a single link that wants to
behave differently from the rest. A timing it leaves out keeps the value from
the options.

```vue
<template>
  <PreviewCard.Trigger :delay="{ mouseenter: 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="{ teleport: { target: '#overlays' } }" />
  <PreviewCard.Root :options="{ teleport: { disabled: true } }" />
</template>
```

Mount a `PreviewCard.Backdrop` for a fixed `<div>` behind the card. It follows
the same teleport settings, so it lands in the same container as the popup. It
takes no pointer events by default, since the pointer has to travel from the
link to the popup, and it has no dismiss behavior of its own: the popup’s
layer already closes on a pointer down outside.

```vue
<template>
  <PreviewCard.Root>
    <PreviewCard.Trigger href="/people/robin">Robin</PreviewCard.Trigger>
    <PreviewCard.Backdrop />
    <PreviewCard.Content>…</PreviewCard.Content>
  </PreviewCard.Root>
</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: { content: '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.

Both transitions broadcast their hooks through the [emitter](#emitter), so
something that renders none of the card’s parts can still wait for the popup
to have finished entering.

### 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-content-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: 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: initial.open
        description: 'Opens the card on mount while it is [uncontrolled](/components/composition#controlled-and-uncontrolled).'
      - label: boolean
      - label: 'false'
  - items:
      - label: delay.mouseenter
        description: 'Milliseconds the pointer rests on the trigger before it [opens](#timing).'
      - label: number
      - label: '600'
  - items:
      - label: delay.mouseleave
        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: keyListener.close
        description: 'Keys that close the card, matched against `event.key`. `false` or `[]` switches closing by key off.'
      - label: 'Array<string> | false'
        escape: true
      - label: '[''Escape'']'
  - items:
      - label: dismiss
        description: 'The interactions outside the card that close it: `''pointerdown''` for a pointer down outside, `''focus''` for focus moving outside. A supplied array replaces the default, so `[]` switches outside dismissal off.'
      - label: 'Array<''pointerdown'' | ''focus''>'
        escape: true
      - label: "['pointerdown', 'focus']"
  - items:
      - label: teleport.target
        description: The teleport target.
      - label: 'string | HTMLElement'
      - label: body
        plaintext: true
  - items:
      - label: teleport.disabled
        description: Renders in place instead.
      - label: boolean
      - label: 'false'
  - items:
      - label: teleport.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.content
        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
---
::

The reason a card opened or closed travels over the
[emitter](#emitter), not over a component emit.

#### 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: 'Partial override of `options.delay`. A timing it leaves out keeps the value the options resolved to.'
      - label: '{ mouseenter?: number, mouseleave?: number }'
      - label: options.delay
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: contentOpen
        description: Mirrors `data-content-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.
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: teleport
        description: The teleport. Configured through `options.teleport`.
      - label: none
        plaintext: true
      - label: nothing
        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-content`. 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 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.Backdrop`

Renders a `<div>` behind the card. You mount it yourself, anywhere under the
root; it teleports into the same container as the popup, appears and disappears
with the open state, and runs the transition named by `transition.backdrop` on
the way in and out. Out of the box it is fixed, covers the viewport and takes
no pointer events, so the pointer can still travel from the link to the popup.
`--mirror-preview-card-backdrop-position`,
`--mirror-preview-card-backdrop-z-index` and
`--mirror-preview-card-backdrop-pointer-events` adjust that. It takes `as` and
`asChild`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: forceMount
        description: Escape hatch over `options.forceMount`.
      - label: 'boolean | undefined'
      - label: options.forceMount
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: open
        description: Mirrors `data-state`.
      - label: boolean
---
::

### `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<PreviewCardChangeReason>
        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?: PreviewCardChangeReason) => void'
        escape: true
  - items:
      - label: close
        description: Closes the card.
      - label: '(reason?: PreviewCardChangeReason) => void'
        escape: true
  - items:
      - label: toggle
        description: Opens or closes.
      - label: '(reason?: PreviewCardChangeReason) => void'
        escape: true
---
::

### Emitter

Every open change is broadcast app-wide through `useMirrorEmitter`, so a
listener needs no shared ancestor with the card. The
`preview-card:openChange` payload carries the instance `id`, the new `open`
state and the `reason` it changed for.

```vue
<script setup lang="ts">
const emitter = useMirrorEmitter()

emitter.on('preview-card:openChange', ({ id, open, reason }) => {
  if (id === PreviewCardId.Author && open) {
    prefetchProfile(reason)
  }
})
</script>
```

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

The six transition hooks are broadcast as well, as `preview-card:beforeEnter`,
`preview-card:enter`, `preview-card:afterEnter`, `preview-card:beforeLeave`,
`preview-card:leave` and `preview-card:afterLeave`. Each payload carries the
instance `id` and the `part` that is transitioning, which is `content` or
`backdrop`.

```vue
<script setup lang="ts">
emitter.on('preview-card:afterEnter', ({ id, part }) => {
  if (id === PreviewCardId.Author && part === 'content') {
    trackImpression()
  }
})
</script>
```

The hooks belong to the Vue transition, so they fire only while
`options.transition.content` or `options.transition.backdrop` names one. Both
are unset by default, and until you name one the card broadcasts no
lifecycle events at all. A card that is already open when it first
renders fires no enter hooks, and a part kept in the DOM with
`options.forceMount`, or with its own `forceMount` prop, never enters or
leaves, so it fires none of them either.

::docs-table
---
columns:
  - label: Event
  - label: Payload
rows:
  - items:
      - label: preview-card:openChange
        description: The card opened or closed.
      - label: "{ id: string, open: boolean, reason: PreviewCardChangeReason }"
        escape: true
  - items:
      - label: preview-card:beforeEnter
        description: A part is about to enter.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: preview-card:enter
        description: A part has started entering.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: preview-card:afterEnter
        description: A part has finished entering.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: preview-card:beforeLeave
        description: A part is about to leave.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: preview-card:leave
        description: A part has started leaving.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: preview-card:afterLeave
        description: A part has finished leaving and is out of the DOM.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
---
::

`PreviewCardLifecyclePayload` types the lifecycle payload, and the whole map is
`PreviewCardEvents`. [Emitter](/components/emitter) covers the channel itself.

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