Skip to content

Preview Card

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

View source View as Markdown

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.

Mirror is built and maintained by Robin Vey
and the team behind Magic as a Service.
App.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; if it only shows a label, use 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.

NameRequiredDescription
PreviewCard.Root true Renders nothing of its own. It is the provider.
PreviewCard.Trigger true Renders an <a>. The card hangs off it and is positioned against it.
PreviewCard.Content true Renders the teleport, the floating box and the popup.
PreviewCard.ArrowOptional. Renders a <div> pointing at the trigger.
<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.

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

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

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

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

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

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

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

<template>
  <PreviewCard.Root
    :options="{ transition: { popup: 'card', backdrop: 'card-backdrop' } }"
  />
</template>
.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.

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

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.

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

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

<template>
  <PreviewCard.Content class="popup" />
</template>
.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

PropTypeDefault
id
stringgenerated
open
boolean | undefinedundefined
defaultOpen
booleanfalse
options
PreviewCardOptionssee below

Options

OptionTypeDefault
delay
number600
closeDelay
number300
disabled
booleanfalse
forceMount
booleanfalse
backdrop
booleanfalse
dismiss.escape
booleantrue
dismiss.pointerDownOutside
booleantrue
dismiss.focusOutside
booleantrue
portal.to
string | HTMLElementbody
portal.disabled
booleanfalse
portal.defer
booleanfalse
floating.side
'top' | 'right' | 'bottom' | 'left''bottom'
floating.sideOffset
number8
floating.align
'start' | 'center' | 'end''center'
floating.alignOffset
number0
floating.strategy
'absolute' | 'fixed''absolute'
floating.flip
booleantrue
floating.shift
booleantrue
floating.collisionPadding
number8
transition.popup
stringundefined
transition.backdrop
stringundefined

Emits

EmitPayload
update:open
boolean
openChange
(value: boolean, reason: PreviewCardOpenReason)

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

Slot props

PropType
open
boolean
side
PreviewCardSide | undefined
align
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

PropTypeDefault
delay
numberoptions.delay
closeDelay
numberoptions.closeDelay

Slot props

PropType
open
boolean
side
PreviewCardSide | undefined
align
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.

LayerElementPublishes
portal
nonenothing
backdrop
div[role=presentation]data-preview-card, data-state
floating
divdata-side, data-align, data-anchor-hidden, data-state
popup
divdata-preview-card, data-side, data-align, data-state

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.

PropTypeDefault
forceMount
booleanoptions.forceMount
anchor
HTMLElement | (() => HTMLElement | null)the trigger
side
'top' | 'right' | 'bottom' | 'left''bottom'
sideOffset
number8
align
'start' | 'center' | 'end''center'
alignOffset
number0
strategy
'absolute' | 'fixed''absolute'
flip
booleantrue
shift
booleantrue
collisionPadding
number8

Slot props

PropType
open
boolean
side
PreviewCardSide
align
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.

KeyType
isOpen
ComputedRef<boolean>
reason
ComputedRef<PreviewCardOpenReason>
side
ComputedRef<PreviewCardSide | undefined>
align
ComputedRef<PreviewCardAlign | undefined>
open
(reason?: PreviewCardOpenReason) => void
close
(reason?: PreviewCardOpenReason) => void
toggle
(reason?: PreviewCardOpenReason) => void

Data attributes

PartAttributeValue
PreviewCard.Trigger, PreviewCard.Content, PreviewCard.Arrow
data-preview-card
the instance ID
PreviewCard.Content
data-scope
backdrop | floating | popup
PreviewCard.Trigger
data-popup-open
true
PreviewCard.Content, PreviewCard.Arrow
data-state
open | closed
PreviewCard.Trigger, PreviewCard.Content, PreviewCard.Arrow
data-side
top | right | bottom | left
PreviewCard.Trigger, PreviewCard.Content, PreviewCard.Arrow
data-align
start | center | end
PreviewCard.Content
data-anchor-hidden
true

CSS variables

VariableDefault
--mirror-preview-card-trigger-cursor
pointer
--mirror-preview-card-floating-z-index
50
--mirror-preview-card-popup-max-height
var(--rack-floating-available-height, none)
--mirror-preview-card-backdrop-position
fixed
--mirror-preview-card-backdrop-z-index
40
--mirror-preview-card-backdrop-pointer-events
none

Errors

Code
missing_preview_card_context
missing_preview_card_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.

KeyBehaviour
Tab on to the triggerOpens the card straight away, with no delay.
Tab off the triggerCloses the card, unless focus moved into the popup.
EscapeCloses the card. Focus never moved, so it stays on the trigger.
Enter on the triggerFollows the link, as on any other link.

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.