moduix

Preview Card

A hover and focus popup that shows a visual preview for a link destination.

API Reference

Original primitive API

Behavior, accessibility details, and low-level props are documented by Base UI.

Base UI API

Basic

The principles of good typography remain in the digital age.

import { PreviewCard, PreviewCardTrigger, PreviewCardContent } from "moduix";const typographyUrl = "https://en.wikipedia.org/wiki/Typography";const typographyImageUrl = "/images/typography-preview.jpg";export function PreviewCardDemo() {  return (    <PreviewCard>      <p className={styles.paragraph}>        The principles of good{" "}        <PreviewCardTrigger href={typographyUrl}>typography</PreviewCardTrigger>{" "}        remain in the digital age.      </p>      <PreviewCardContent>        <div className={styles.popupContent}>          <img            className={styles.image}            alt="Preview illustration for Typography"            src={typographyImageUrl}          />          <p className={styles.summary}>            <strong>Typography</strong>            <br />            Typography is the art and technique of arranging type to make written language            readable and expressive.          </p>        </div>      </PreviewCardContent>    </PreviewCard>  );}
.paragraph {  max-width: 33rem;  margin: 0;  color: var(--color-foreground);  font-size: var(--text-md);  line-height: var(--line-height-text-md);}.popupContent {  display: grid;  gap: var(--spacing-2);}.image {  display: block;  width: 14rem;  max-width: min(14rem, calc(100vw - 4rem));  border-radius: var(--radius-sm);  object-fit: cover;}.summary {  max-width: 14rem;  margin: 0;  color: inherit;  font-size: var(--text-sm);  line-height: var(--line-height-text-sm);}

Full list of component variables available for project-level overrides.

PropertyDefaultDescription
--preview-card-arrow-height0.625remControls the default arrow SVG height.
--preview-card-arrow-inline-offset0.8125remControls the inline-axis arrow offset.
--preview-card-arrow-size0.5remControls the block-axis arrow offset.
--preview-card-arrow-stroke-colorvar(--preview-card-border-color)Controls arrow border color.
--preview-card-arrow-width1.25remControls the default arrow SVG width.
--preview-card-backdrop-bgvar(--backdrop-bg, transparent)Controls the backdrop color.
--preview-card-backdrop-blur0Controls the backdrop blur.
--preview-card-backdrop-transitionvar(--transition-default)Controls the backdrop transition.
--preview-card-bgvar(--color-popover)Controls the popup background color.
--preview-card-border-colorvar(--color-border)Controls the popup border color.
--preview-card-border-widthvar(--border-width-sm)Controls popup border width.
--preview-card-colorvar(--color-popover-foreground)Controls the popup text color.
--preview-card-disabled-opacityvar(--opacity-disabled)Controls disabled opacity.
--preview-card-focus-ring-colorvar(--color-ring)Controls the trigger focus ring color.
--preview-card-focus-ring-widthvar(--border-width-sm)Controls focus ring width.
--preview-card-heightautoControls the popup height.
--preview-card-max-height24remControls the popup max height.
--preview-card-max-width24remControls the popup max width.
--preview-card-min-width14remControls the popup min width.
--preview-card-padding-xvar(--spacing-2)Controls the popup horizontal padding.
--preview-card-padding-yvar(--spacing-2)Controls the popup vertical padding.
--preview-card-radiusvar(--radius-lg)Controls the popup border radius.
--preview-card-scalevar(--scale-popup)Controls the popup enter and exit scale.
--preview-card-shadowvar(--shadow-lg)Controls the popup shadow.
--preview-card-transitionvar(--transition-default)Controls popup and trigger transitions.
--preview-card-trigger-colorvar(--color-primary)Controls the default trigger text color.
--preview-card-trigger-decoration-colorcolor-mix(in oklab, var(--preview-card-trigger-color), transparent 40%)Controls the trigger underline color.
--preview-card-trigger-decoration-color-hovervar(--preview-card-trigger-color)Controls the trigger underline color on hover.
--preview-card-trigger-decoration-color-openvar(--preview-card-trigger-color)Controls the trigger underline color while the popup is open.
--preview-card-trigger-decoration-thickness1pxControls the trigger underline thickness.
--preview-card-trigger-focus-offset1pxControls the trigger focus outline offset.
--preview-card-trigger-focus-radiusvar(--radius-xs)Controls the trigger focus radius.
--preview-card-trigger-underline-offset2pxControls the trigger underline offset.
--preview-card-widthautoControls the popup width.

Interactive variables scoped for docs preview without changing size scale tokens.

PropertyValueDefaultDescription
--preview-card-backdrop-bgvar(--backdrop-bg, transparent)Controls backdrop color.
--preview-card-bgvar(--color-popover)Controls popup background color.
--preview-card-border-colorvar(--color-border)Controls popup border color.
--preview-card-colorvar(--color-popover-foreground)Controls popup text color.
--preview-card-focus-ring-colorvar(--color-ring)Controls trigger focus ring color.
--preview-card-radiusvar(--radius-lg)Controls popup border radius.
--preview-card-shadowvar(--shadow-lg)Controls popup shadow.
--preview-card-trigger-colorvar(--color-primary)Controls trigger text color.

Anatomy

PreviewCard pairs one or more triggers with one popup. Consumers compose only the root state, visible trigger, and content; PreviewCardContent renders the portal, optional backdrop, positioner, popup, arrow, and viewport internally.

Preview cards are a visual enhancement for hover and focus. Keep essential information available on the linked destination or in nearby page content, because touch and screen reader users may not receive the same popup preview.

PreviewCard
├─ PreviewCardTrigger
└─ PreviewCardContent
   ├─ portal (internal)
   ├─ backdrop (internal, optional)
   ├─ positioner (internal)
   ├─ popup
   │  ├─ arrow (internal, optional)
   │  └─ viewport (internal)
   │     └─ content
   └─ placement state
<PreviewCard>
  <PreviewCardTrigger href="https://en.wikipedia.org/wiki/Typography">
    typography
  </PreviewCardTrigger>
  <PreviewCardContent>
    <div className={styles.popupContent}>Preview content</div>
  </PreviewCardContent>
</PreviewCard>
PartRole
PreviewCardRoot state machine. Controls open state, delays, payload, and detached trigger handles.
PreviewCardTriggerInteractive link or trigger. It opens the preview on hover or focus and receives open/disabled state data.
PreviewCardContentPublic popup slot. It owns placement props, popup styling, optional arrow, backdrop, and service slots.
portalInternal service layer that moves the popup to document.body or the supplied container.
positionerInternal service layer that measures the trigger and writes placement variables.
backdropInternal optional overlay enabled with withBackdrop.
viewportInternal transition container for content changes between payload-driven triggers.

Style the popup with className on PreviewCardContent. Use classNames only for internal service slots such as backdrop, positioner, arrow, and viewport when those slots need targeted styling.

Composition

Use PreviewCard for root behavior props such as open, defaultOpen, onOpenChange, delay, closeDelay, and handle. Use PreviewCardTrigger for the link itself; detached or shared triggers can be connected with createPreviewCardHandle.

PreviewCardContent owns the rendered popup and its service slots. Pass placement props such as side, align, sideOffset, alignOffset, collisionBoundary, and collisionPadding directly to PreviewCardContent. Use container to choose the portal container, withBackdrop to enable the optional backdrop, withArrow={false} to remove the default arrow, and arrow to replace it. Use slotProps only when you need to pass Base UI escape-hatch props to an internal slot, such as keepMounted on the portal or a custom render for the viewport.

Examples

Controlled

Control the open state from React when the preview needs to coordinate with other UI.

import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from "moduix";import { useState } from "react";export function ControlledPreviewCard() {  const [open, setOpen] = useState(false);  const typographyUrl = "https://en.wikipedia.org/wiki/Typography";  return (    <PreviewCard open={open} onOpenChange={setOpen}>      <PreviewCardTrigger href={typographyUrl}>Controlled preview card</PreviewCardTrigger>      <PreviewCardContent>        <div className={styles.popupContent}>          <p className={styles.summary}>            This preview card is controlled with open and onOpenChange.          </p>        </div>      </PreviewCardContent>    </PreviewCard>  );}
.popupContent {  display: grid;  gap: var(--spacing-2);}.summary {  max-width: 14rem;  margin: 0;  color: inherit;  font-size: var(--text-sm);  line-height: var(--line-height-text-sm);}

Detached Trigger

Use createPreviewCardHandle when the trigger and popup content live in different parts of the tree.

import { createPreviewCardHandle, PreviewCardTrigger, PreviewCard, PreviewCardContent } from "moduix";import { useMemo } from "react";export function DetachedTriggerPreviewCard() {  const previewCardHandle = useMemo(() => createPreviewCardHandle(), []);  const typographyUrl = "https://en.wikipedia.org/wiki/Typography";  return (    <div className={styles.row}>      <PreviewCardTrigger handle={previewCardHandle} href={typographyUrl}>        Open detached preview      </PreviewCardTrigger>      <PreviewCard handle={previewCardHandle}>        <PreviewCardContent>          <div className={styles.popupContent}>            <p className={styles.summary}>              Trigger and popup are linked with createPreviewCardHandle().            </p>          </div>        </PreviewCardContent>      </PreviewCard>    </div>  );}
.popupContent {  display: grid;  gap: var(--spacing-2);}.summary {  max-width: 14rem;  margin: 0;  color: inherit;  font-size: var(--text-sm);  line-height: var(--line-height-text-sm);}.row {  display: flex;  flex-wrap: wrap;  align-items: center;  justify-content: center;  gap: var(--spacing-2);}

Multiple Triggers With Payload

Share one popup between several links and pass trigger-specific data through payload.

import {  createPreviewCardHandle,  PreviewCard,  PreviewCardContent,  PreviewCardTrigger,} from "moduix";import { useMemo } from "react";type LinkPreviewPayload = {  title: string;  url: string;  summary: string;};export function MultipleTriggersPreviewCard() {  const previewCardHandle = useMemo(() => createPreviewCardHandle(), []);  return (    <div className={styles.row}>      {items.map((item) => (        <PreviewCardTrigger          key={item.title}          handle={previewCardHandle}          href={item.url}          payload={item}        >          {item.title}        </PreviewCardTrigger>      ))}      <PreviewCard handle={previewCardHandle}>{renderPreview}</PreviewCard>    </div>  );  function renderPreview({ payload }) {    const item = (payload as LinkPreviewPayload | undefined) ?? items[0];    return (      <PreviewCardContent>        <div className={styles.popupContent}>          <p className={styles.summary}>            <strong>{item.title}</strong>            <br />            {item.summary}          </p>        </div>      </PreviewCardContent>    );  }}
.popupContent {  display: grid;  gap: var(--spacing-2);}.summary {  max-width: 14rem;  margin: 0;  color: inherit;  font-size: var(--text-sm);  line-height: var(--line-height-text-sm);}.row {  display: flex;  flex-wrap: wrap;  align-items: center;  justify-content: center;  gap: var(--spacing-2);}
const items: LinkPreviewPayload[] = [  {    title: "Typography",    url: "https://en.wikipedia.org/wiki/Typography",    summary: "Typography is the art and technique of arranging type.",  },  {    title: "Grid systems",    url: "https://en.wikipedia.org/wiki/Grid_(graphic_design)",    summary: "Grid systems help organize content and spacing.",  },];

Side Control

Pass side, align, offsets, collision options, or slotProps.positioner to PreviewCardContent when you need to control popup placement.

Placement: bottom
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from "moduix";import { useState } from "react";export function SideControlPreviewCard() {  const [side, setSide] = useState("bottom" as (typeof sides)[number]);  const typographyUrl = "https://en.wikipedia.org/wiki/Typography";  return (    <div className={styles.stack}>      <div className={styles.sideButtons}>        {sides.map((item) => (          <button            key={item}            type="button"            className={styles.sideButton}            data-active={item === side || undefined}            onClick={() => setSide(item)}          >            {item}          </button>        ))}      </div>      <PreviewCard>        <PreviewCardTrigger href={typographyUrl}>Placement: {side}</PreviewCardTrigger>        <PreviewCardContent side={side} className={styles.sidePopup}>          <div className={styles.popupContent}>            <p className={styles.summary}>Current side is {side}.</p>          </div>        </PreviewCardContent>      </PreviewCard>    </div>  );}
.popupContent {  display: grid;  gap: var(--spacing-2);}.summary {  max-width: 14rem;  margin: 0;  color: inherit;  font-size: var(--text-sm);  line-height: var(--line-height-text-sm);}.stack {  display: grid;  justify-items: center;  gap: var(--spacing-3);}.sideButtons {  display: inline-flex;  flex-wrap: wrap;  align-items: center;  gap: var(--spacing-1);  padding: var(--spacing-1);  border: var(--border-width-sm) solid var(--color-border);  border-radius: var(--radius-md);  background: var(--color-muted);}.sideButton {  min-width: 4.75rem;  min-height: 2rem;  padding: 0 var(--spacing-3);  border: var(--border-width-sm) solid var(--color-border);  border-radius: var(--radius-sm);  background: var(--color-background);  color: var(--color-foreground);  font: inherit;  font-size: var(--text-sm);  font-weight: var(--weight-medium);  line-height: var(--line-height-text-sm);  text-transform: capitalize;  cursor: pointer;  transition:    background-color var(--transition-default),    color var(--transition-default);  &[data-active] {    background: var(--color-primary);    color: var(--color-primary-foreground);  }}.sidePopup {  --preview-card-max-width: 17rem;}
const sides = ["top", "right", "bottom", "left"] as const;

Custom Arrow

Pass arrow to PreviewCardContent to render any custom icon or SVG.

import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from "moduix";export function CustomArrowPreviewCard() {  return (    <PreviewCard>      <PreviewCardTrigger href="https://en.wikipedia.org/wiki/Typography">        Preview with custom arrow      </PreviewCardTrigger>      <PreviewCardContent className={styles.customArrowPopup} arrow={<CustomArrow />}>        <div className={styles.popupContent}>          <p className={styles.summary}>            Pass any React node to the arrow prop to use a custom SVG or icon.          </p>        </div>      </PreviewCardContent>    </PreviewCard>  );}function CustomArrow() {  return (    <svg className={styles.customArrow} viewBox="0 0 16 8" fill="none" aria-hidden="true">      <path d="M8 0L16 8H0L8 0Z" fill="currentColor" />    </svg>  );}
.popupContent {  display: grid;  gap: var(--spacing-2);}.summary {  max-width: 14rem;  margin: 0;  color: inherit;  font-size: var(--text-sm);  line-height: var(--line-height-text-sm);}.customArrowPopup {  --preview-card-bg: var(--color-foreground);  --preview-card-color: var(--color-background);  --preview-card-border-color: color-mix(in oklab, var(--color-foreground), transparent 25%);}.customArrow {  display: block;  width: 1rem;  height: 0.5rem;  color: var(--preview-card-bg, var(--color-popover));}

Custom Styles

PreviewCardContent renders the portal, backdrop, positioner, and viewport internally. Use classNames and slotProps when you need to style or configure those internal slots.

import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from "moduix";export function SlotCustomizationPreviewCard() {  return (    <PreviewCard>      <PreviewCardTrigger className={styles.slotTrigger} href="https://en.wikipedia.org/wiki/Typography">        Preview with styled slots      </PreviewCardTrigger>      <PreviewCardContent        withBackdrop        classNames={{          backdrop: styles.backdrop,          arrow: styles.slotArrow,          viewport: styles.viewport,        }}      >        <div className={styles.popupContent}>          <p className={styles.summary}>            Portal, backdrop, positioner, and viewport are rendered by PreviewCardContent.          </p>        </div>      </PreviewCardContent>    </PreviewCard>  );}
.popupContent {  display: grid;  gap: var(--spacing-2);}.summary {  max-width: 14rem;  margin: 0;  color: inherit;  font-size: var(--text-sm);  line-height: var(--line-height-text-sm);}.backdrop {  --preview-card-backdrop-bg: color-mix(in oklab, var(--color-background), transparent 70%);  --preview-card-backdrop-blur: 2px;  --preview-card-backdrop-transition: 200ms ease;}.slotTrigger {  position: relative;  z-index: calc(var(--z-popup) + 1);}.slotArrow {  --preview-card-arrow-stroke-color: var(--preview-card-border-color);}.viewport {  padding: var(--spacing-1);}

On this page