Preview Card

Preview Cards are visual-only, interactive popups that—when hovered—display a concise preview of the content that a link navigates to.

Give FeedbackBundle Size

Introduction

@Base_UI
'use client';
import * as React from 'react';
import { PreviewCard } from '@base_ui/react/PreviewCard';
import { styled } from '@mui/system';

export default function UnstyledPreviewCardIntroduction() {
  return (
    <PreviewCard.Root>
      <TriggerLink href="#">@Base_UI</TriggerLink>
      <PreviewCard.Positioner sideOffset={8}>
        <PreviewCardPopup>
          <img
            src="https://pbs.twimg.com/profile_images/1798056009291997184/B-prVmUP_400x400.jpg"
            alt="Base UI Logo"
            width={80}
            height={80}
            style={{ borderRadius: '50%' }}
          />
          <h2 style={{ fontSize: 20, margin: 0 }}>Base UI</h2>
          <p>Unstyled React components and hooks (@base_ui/react), by @MUI_hq.</p>
          <div style={{ display: 'flex', gap: 10 }}>
            <span>
              <strong>1</strong> Following
            </span>
            <span>
              <strong>1,000</strong> Followers
            </span>
          </div>
          <PreviewCardArrow />
        </PreviewCardPopup>
      </PreviewCard.Positioner>
    </PreviewCard.Root>
  );
}

const blue = {
  400: '#3399FF',
  600: '#0072E6',
  800: '#004C99',
};

export const PreviewCardPopup = styled(PreviewCard.Popup)`
  position: relative;
  background: white;
  color: black;
  border-radius: 10px;
  filter: drop-shadow(0 2px 4px rgb(0 10 20 / 0.25));
  outline: 0;
  padding: 12px 16px;
  max-width: min(300px, var(--available-width));
`;

export const TriggerLink = styled(PreviewCard.Trigger)`
  border: none;
  color: ${blue[600]};
  font-size: 18px;
  text-decoration: none;

  &:hover {
    color: ${blue[800]};
  }

  &:focus-visible {
    outline: 2px solid ${blue[400]};
    outline-offset: 2px;
  }
`;

export const PreviewCardArrow = styled(PreviewCard.Arrow)`
  width: 10px;
  height: 10px;
  transform: rotate(45deg);
  background: white;
  z-index: -1;

  &[data-side='top'] {
    bottom: -5px;
  }

  &[data-side='right'] {
    left: -5px;
  }

  &[data-side='bottom'] {
    top: -5px;
  }

  &[data-side='left'] {
    right: -5px;
  }
`;

Installation

Base UI components are all available as a single package.

npm install @base_ui/react

Once you have the package installed, import the component.

import { PreviewCard } from '@base_ui/react/PreviewCard';

Anatomy

Preview Card is implemented using a collection of related components:

<PreviewCard.Root>
  <PreviewCard.Trigger />
  <PreviewCard.Backdrop />
  <PreviewCard.Positioner>
    <PreviewCard.Popup>
      <PreviewCard.Arrow />
    </PreviewCard.Popup>
  </PreviewCard.Positioner>
</PreviewCard.Root>

Accessibility

Preview Cards are a type of progressive enhancement component to display a concise preview of a link before a sighted user decides to commit to navigating to the link's new location.

Guidelines:

Placement

By default, the Preview Card is placed on the bottom side of its trigger, the default anchor. To change this, use the side prop:

<PreviewCard.Root>
  <PreviewCard.Trigger />
  <PreviewCard.Positioner side="right">
    <PreviewCard.Popup>Preview Card</PreviewCard.Popup>
  </PreviewCard.Positioner>
</PreviewCard.Root>

You can also change the alignment of the Preview Card in relation to its anchor. By default, it is centered, but it can be aligned to an edge of the anchor using the alignment prop:

<PreviewCard.Positioner side="right" alignment="start">
  <PreviewCard.Popup>Preview Card</PreviewCard.Popup>
</PreviewCard.Positioner>

Due to collision detection, the Preview Card may change its placement to avoid overflow. Therefore, your explicitly specified side and alignment props act as "ideal", or preferred, values.

To access the true rendered values, which may change as the result of a collision, the popup element receives data attributes:

// Rendered HTML (simplified)
<div>
  <div data-side="left" data-alignment="end">
    Preview Card
  </div>
</div>

This allows you to conditionally style the Preview Card based on its rendered side or alignment.

Offset

The sideOffset prop creates a gap between the anchor and Preview Card popup, while alignmentOffset slides the Preview Card popup from its alignment, acting logically for start and end alignments.

<PreviewCard.Positioner sideOffset={10} alignmentOffset={10}>

Delay

To change how long the Preview Card waits until it opens or closes, use the delay and closeDelay props, which represent how long the Preview Card waits after the cursor rests on the trigger to open, or moves away from the trigger to close, in milliseconds:

<PreviewCard.Root delay={200} closeDelay={200}>

Controlled

To control the Preview Card with external state, use the open and onOpenChange props:

function App() {
  const [open, setOpen] = React.useState(false);
  return (
    <PreviewCard.Root open={open} onOpenChange={setOpen}>
      {/* Subcomponents */}
    </PreviewCard.Root>
  );
}

Arrow

To add an arrow (caret or triangle) inside the Preview Card content that points toward the center of the anchor element, use the PreviewCard.Arrow component:

<PreviewCard.Positioner>
  <PreviewCard.Popup>
    <PreviewCard.Arrow />
    Preview Card
  </PreviewCard.Popup>
</PreviewCard.Positioner>

It automatically positions a wrapper element that can be styled or contain a custom SVG shape.

Backdrop

You may dim content behind the Preview Card in order to draw more attention to it by rendering an optional backdrop.

<PreviewCard.Root>
  <PreviewCard.Backdrop />
  {/* Subcomponents */}
</PreviewCard.Root>

It has the same maximum z-index as the Positioner component by default, and should be placed before it in the React tree.

Anchoring

By default, the Trigger acts as the anchor, but this can be changed to another element.

<PreviewCard.Positioner anchor={anchorNode}>
<PreviewCard.Positioner anchor={anchorRef}>
<PreviewCard.Positioner
  anchor={{
    getBoundingClientRect: () => DOMRect,
    // `contextElement` is an optional but recommended property when `getBoundingClientRect` is
    // derived from a real element, to ensure collision detection and position updates work as
    // expected in certain DOM trees.
    contextElement: domNode,
  }}
>

Styling

The PreviewCard.Positioner element receives the following CSS variables, which can be used by PreviewCard.Popup:

Large content

If your Preview Card is large enough that it cannot fit inside the viewport (especially on small or narrow screens as on mobile devices), the --available-width and --available-height properties are useful to constrain its size to prevent it from overflowing.

.PreviewCardPopup {
  max-width: var(--available-width);
  max-height: var(--available-height);
  overflow: auto;
}

The overflow: auto property will prevent the Arrow from appearing, if specified. You can instead place this on a wrapper child inside the Popup:

<PreviewCard.Popup className="PreviewCardPopup">
  <PreviewCard.Arrow />
  <div className="PreviewCardPopup-content">Large content</div>
</PreviewCard.Popup>
.PreviewCardPopup-content {
  max-width: var(--available-width);
  max-height: var(--available-height);
  overflow: auto;
}

Absolute maximums can also be specified if the Preview Card's size can be too large on wider or bigger screens:

.PreviewCardPopup-content {
  max-width: min(500px, var(--available-width));
  max-height: min(500px, var(--available-height));
  overflow: auto;
}

Animations

The Preview Card can animate when opening or closing with either:

CSS transitions

Here is an example of how to apply a symmetric scale and fade transition with the default conditionally-rendered behavior:

<PreviewCard.Popup className="PreviewCardPopup">Preview Card</PreviewCard.Popup>
.PreviewCardPopup {
  transform-origin: var(--transform-origin);
  transition-property: opacity, transform;
  transition-duration: 0.2s;
  /* Represents the final styles once exited */
  opacity: 0;
  transform: scale(0.9);
}
 
/* Represents the final styles once entered */
.PreviewCardPopup[data-open] {
  opacity: 1;
  transform: scale(1);
}
 
/* Represents the initial styles when entering */
.PreviewCardPopup[data-entering] {
  opacity: 0;
  transform: scale(0.9);
}

Styles need to be applied in three states:

Trigger
'use client';
import * as React from 'react';
import { PreviewCard } from '@base_ui/react/PreviewCard';
import { styled } from '@mui/system';

export default function UnstyledPreviewCardTransition() {
  return (
    <div style={{ display: 'flex', gap: 12 }}>
      <PreviewCard.Root>
        <TriggerLink href="#">Trigger</TriggerLink>
        <PreviewCard.Positioner sideOffset={5}>
          <PreviewCardPopup>
            <img
              src="https://pbs.twimg.com/profile_images/1798056009291997184/B-prVmUP_400x400.jpg"
              alt="Base UI Logo"
              width={80}
              height={80}
              style={{ borderRadius: '50%' }}
            />
            <h2 style={{ fontSize: 20, margin: 0 }}>Base UI</h2>
            <p>Unstyled React components and hooks (@base_ui/react), by @MUI_hq.</p>
            <div style={{ display: 'flex', gap: 10 }}>
              <span>
                <strong>1</strong> Following
              </span>
              <span>
                <strong>1,000</strong> Followers
              </span>
            </div>
          </PreviewCardPopup>
        </PreviewCard.Positioner>
      </PreviewCard.Root>
    </div>
  );
}

const blue = {
  400: '#3399FF',
  600: '#0072E6',
  800: '#004C99',
};

export const PreviewCardPopup = styled(PreviewCard.Popup)`
  position: relative;
  background: white;
  color: black;
  border-radius: 5px;
  filter: drop-shadow(0 0.1rem 0.25rem rgb(0 10 20 / 0.25));
  outline: 0;
  padding: 12px 16px;
  max-width: min(300px, var(--available-width));
  transition-property: opacity, transform;
  transition-duration: 0.2s;
  opacity: 0;
  transform-origin: var(--transform-origin);

  &[data-open] {
    opacity: 1;
    transform: scale(1);
  }

  &[data-entering] {
    opacity: 0;
    transform: scale(0.95);
  }
`;

export const TriggerLink = styled(PreviewCard.Trigger)`
  border: none;
  color: ${blue[600]};
  font-size: 18px;
  text-decoration: none;

  &:hover {
    color: ${blue[800]};
  }

  &:focus-visible {
    outline: 2px solid ${blue[400]};
    outline-offset: 2px;
  }
`;

In newer browsers, there is a feature called @starting-style which allows transitions to occur on open for conditionally-mounted components:

/* Base UI API - Polyfill */
.PreviewCardPopup[data-entering] {
  opacity: 0;
  transform: scale(0.9);
}
 
/* Official Browser API - no Firefox support as of May 2024 */
@starting-style {
  .PreviewCardPopup[data-open] {
    opacity: 0;
    transform: scale(0.9);
  }
}

CSS animations

CSS animations can also be used, requiring only two separate declarations:

@keyframes scale-in {
  from {
    opacity: 0;
    transform: scale(0.9);
  }
}
 
@keyframes scale-out {
  to {
    opacity: 0;
    transform: scale(0.9);
  }
}
 
.PreviewCardPopup {
  animation: scale-in 0.2s forwards;
}
 
.PreviewCardPopup[data-exiting] {
  animation: scale-out 0.2s forwards;
}

JavaScript animations

The keepMounted prop lets an external library control the mounting, for example framer-motion's AnimatePresence component.

function App() {
  const [open, setOpen] = useState(false);
  return (
    <PreviewCard.Root open={open} onOpenChange={setOpen}>
      <PreviewCard.Trigger>Trigger</PreviewCard.Trigger>
      <AnimatePresence>
        {open && (
          <PreviewCard.Positioner keepMounted>
            <PreviewCard.Popup
              render={
                <motion.div
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                />
              }
            >
              Preview Card
            </PreviewCard.Popup>
          </PreviewCard.Positioner>
        )}
      </AnimatePresence>
    </PreviewCard.Root>
  );
}

Animation states

Four states are available as data attributes to animate the popup, which enables full control depending on whether the popup is being animated with CSS transitions or animations, JavaScript, or is using the keepMounted prop.

Overriding default components

Use the render prop to override the rendered elements with your own components.

// Element shorthand
<PreviewCard.Popup render={<MyPreviewCardPopup />} />
// Function
<PreviewCard.Popup render={(props) => <MyPreviewCardPopup {...props} />} />

API Reference

PreviewCardRoot

PropTypeDefaultDescription
animatedbooltrueWhether the preview card can animate, adding animation-related attributes and allowing for exit animations to play. Useful to disable in tests to remove async behavior.
closeDelaynumber300The delay in milliseconds until the preview card popup is closed when openOnHover is true.
defaultOpenboolfalseWhether the preview card popup is open by default. Use when uncontrolled.
delaynumber600The delay in milliseconds until the preview card popup is opened when openOnHover is true.
onOpenChangefuncCallback fired when the preview card popup is requested to be opened or closed. Use when controlled.
openboolfalseWhether the preview card popup is open. Use when controlled.

PreviewCardTrigger

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

PreviewCardPositioner

PropTypeDefaultDescription
alignmentenum'center'The alignment of the preview card element to the anchor element along its cross axis.
alignmentOffsetnumber0The offset of the preview card element along its alignment axis.
anchorunionThe anchor element to which the preview card popup will be placed at.
arrowPaddingnumber5Determines the padding between the arrow and the preview card popup's edges. Useful when the preview card popup has rounded corners via border-radius.
classNameunionClass names applied to the element or a function that returns them based on the component's state.
collisionBoundaryunion'clippingAncestors'The boundary that the preview card element should be constrained to.
collisionPaddingunion5The padding of the collision boundary.
containerunionThe container element to which the preview card popup will be appended to.
hideWhenDetachedboolfalseIf true, the preview card will be hidden if it is detached from its anchor element due to differing clipping contexts.
keepMountedboolfalseIf true, preview card stays mounted in the DOM when closed.
positionMethodenum'absolute'The CSS position strategy for positioning the preview card popup element.
renderunionA function to customize rendering of the component.
sideenum'bottom'The side of the anchor element that the preview card element should align to.
sideOffsetnumber0The gap between the anchor element and the preview card element.
stickyboolfalseIf true, allow the preview card to remain in stuck view while the anchor element is scrolled out of view.

PreviewCardPopup

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

PreviewCardArrow

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
hideWhenUncenteredboolfalseWhether the Arrow is hidden when it can't point to the center of the anchor element.
renderunionA function to customize rendering of the component.

PreviewCardBackdrop

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
containerunionThe element the Backdrop is appended to.
keepMountedboolfalseWhether the Backdrop remains mounted when the Preview Card Popup is closed.
renderunionA function to customize rendering of the component.

Contents