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.
<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, useTooltip. - Leave
delaywhere 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.Contentteleports to the end of<body>unless you turn that off withoptions.portal.disabled. Leave it on where the link sits inside anything withoverflow: hidden.- The content resolves everything but
sideandalignonce, 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.
| Name | Required | Description |
|---|---|---|
| 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.Arrow | Optional. 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
Options
Emits
PreviewCardOpenReason is trigger-hover, trigger-focus, trigger-press,
outside-press, escape-key, imperative-action or none.
Slot props
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
Slot props
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.
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.
Slot props
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.
Data attributes
CSS variables
Errors
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.
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.