Propel

Preview Card

A non-modal rich preview that opens on hover or focus of an inline link.

Plane
Show code
import {
  PreviewCard,
  PreviewCardBody,
  PreviewCardContent,
  PreviewCardDescription,
  PreviewCardTitle,
  PreviewCardTrigger,
} from "@makeplane/propel/components/preview-card";

export default function BasicDemo() {
  return (
    <PreviewCard>
      <PreviewCardTrigger href="https://plane.so">Plane</PreviewCardTrigger>
      <PreviewCardContent side="top">
        <PreviewCardBody>
          <PreviewCardTitle>Plane</PreviewCardTitle>
          <PreviewCardDescription>
            Open-source project management for issues, sprints, and roadmaps.
          </PreviewCardDescription>
        </PreviewCardBody>
      </PreviewCardContent>
    </PreviewCard>
  );
}

Installation

import {
  PreviewCard,
  PreviewCardTrigger,
  PreviewCardContent,
  PreviewCardBody,
  PreviewCardTitle,
  PreviewCardDescription,
} from "@makeplane/propel/components/preview-card";

Usage

import {
  PreviewCard,
  PreviewCardBody,
  PreviewCardContent,
  PreviewCardDescription,
  PreviewCardTitle,
  PreviewCardTrigger,
} from "@makeplane/propel/components/preview-card";

export default function BasicDemo() {
  return (
    <PreviewCard>
      <PreviewCardTrigger href="https://plane.so">Plane</PreviewCardTrigger>
      <PreviewCardContent side="top">
        <PreviewCardBody>
          <PreviewCardTitle>Plane</PreviewCardTitle>
          <PreviewCardDescription>
            Open-source project management for issues, sprints, and roadmaps.
          </PreviewCardDescription>
        </PreviewCardBody>
      </PreviewCardContent>
    </PreviewCard>
  );
}

Examples

With image

Show code
import {
  PreviewCard,
  PreviewCardBody,
  PreviewCardContent,
  PreviewCardDescription,
  PreviewCardImage,
  PreviewCardTitle,
  PreviewCardTrigger,
} from "@makeplane/propel/components/preview-card";

export default function WithImageDemo() {
  return (
    <PreviewCard>
      <PreviewCardTrigger href="https://plane.so/brand">brand guidelines</PreviewCardTrigger>
      <PreviewCardContent sideOffset={8}>
        <PreviewCardImage
          src="https://images.unsplash.com/photo-1619615391095-dfa29e1672ef?q=80&w=296&h=198&fit=crop"
          alt="Station signage set in large, high-contrast lettering"
          width={296}
          height={198}
        />
        <PreviewCardBody>
          <PreviewCardTitle>Brand guidelines</PreviewCardTitle>
          <PreviewCardDescription>
            Typography, color, and logo usage for everything Plane ships.
          </PreviewCardDescription>
        </PreviewCardBody>
      </PreviewCardContent>
    </PreviewCard>
  );
}

With properties

Show code
import { Avatar } from "@makeplane/propel/components/avatar";
import { Icon } from "@makeplane/propel/components/icon";
import { PillButton } from "@makeplane/propel/components/pill";
import {
  PreviewCard,
  PreviewCardBody,
  PreviewCardContent,
  PreviewCardDescription,
  PreviewCardEyebrow,
  PreviewCardEyebrowLabel,
  PreviewCardPropertyGroup,
  PreviewCardTitle,
  PreviewCardTrigger,
} from "@makeplane/propel/components/preview-card";
import { CircleDashed, CircleDot, SignalHigh } from "lucide-react";

export default function WithPropertiesDemo() {
  return (
    <PreviewCard>
      <PreviewCardTrigger href="https://app.plane.so/issues/WEB-142">WEB-142</PreviewCardTrigger>
      <PreviewCardContent side="top">
        <PreviewCardBody>
          <PreviewCardEyebrow>
            {/* Figma's issue frame uses a 16×16 filled type indicator here; Icon is a stand-in. */}
            <Icon icon={CircleDot} tint="secondary" size="md" />
            <PreviewCardEyebrowLabel>WEB-142</PreviewCardEyebrowLabel>
          </PreviewCardEyebrow>
          <PreviewCardTitle>Redesign the pricing page</PreviewCardTitle>
          <PreviewCardDescription>
            Rework the tiered layout to highlight the annual plan discount.
          </PreviewCardDescription>
          <PreviewCardPropertyGroup>
            <PillButton size="xs" startIcon={<Icon icon={CircleDashed} />} label="Draft" />
            <PillButton size="xs" startIcon={<Icon icon={SignalHigh} />} label="High" />
            <Avatar
              size="xs"
              alt="Priya Sharma"
              src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=40&h=40&fit=crop&crop=faces"
              fallback="P"
            />
          </PreviewCardPropertyGroup>
        </PreviewCardBody>
      </PreviewCardContent>
    </PreviewCard>
  );
}

Controlled

A controlled card owns its own visibility through open / onOpenChange, and triggerId selects which trigger it anchors to — letting application code open the card programmatically.

Group sprint work into cycles or feature work into modules.

Show code
import { Button } from "@makeplane/propel/components/button";
import {
  PreviewCard,
  PreviewCardBody,
  PreviewCardContent,
  PreviewCardDescription,
  PreviewCardTitle,
  PreviewCardTrigger,
} from "@makeplane/propel/components/preview-card";
import * as React from "react";

const topics: Record<string, { title: string; description: string }> = {
  cycles: {
    title: "Cycles",
    description: "Time-boxed iterations for planning and shipping work in focused sprints.",
  },
  modules: {
    title: "Modules",
    description: "Larger bodies of related work items tracked together across cycles.",
  },
};

export default function ControlledDemo() {
  const [open, setOpen] = React.useState(false);
  const [triggerId, setTriggerId] = React.useState<string | null>(null);
  return (
    <div className="flex flex-col items-start gap-4">
      <PreviewCard
        open={open}
        triggerId={triggerId}
        onOpenChange={(nextOpen, eventDetails) => {
          setOpen(nextOpen);
          setTriggerId(eventDetails.trigger?.id ?? null);
        }}
      >
        {({ payload }) => {
          const topic = typeof payload === "string" ? topics[payload] : undefined;
          return (
            <>
              <p className="max-w-prose text-body-sm-regular text-secondary">
                Group sprint work into{" "}
                <PreviewCardTrigger
                  id="cycles-trigger"
                  payload="cycles"
                  href="https://docs.plane.so/core-concepts/cycles"
                >
                  cycles
                </PreviewCardTrigger>{" "}
                or feature work into{" "}
                <PreviewCardTrigger
                  id="modules-trigger"
                  payload="modules"
                  href="https://docs.plane.so/core-concepts/modules"
                >
                  modules
                </PreviewCardTrigger>
                .
              </p>
              <PreviewCardContent sideOffset={8}>
                {topic === undefined ? null : (
                  <PreviewCardBody>
                    <PreviewCardTitle>{topic.title}</PreviewCardTitle>
                    <PreviewCardDescription>{topic.description}</PreviewCardDescription>
                  </PreviewCardBody>
                )}
              </PreviewCardContent>
            </>
          );
        }}
      </PreviewCard>
      <Button
        variant="secondary"
        size="md"
        stretch="auto"
        onClick={() => {
          setTriggerId("modules-trigger");
          setOpen(true);
        }}
        label="Preview modules"
      />
    </div>
  );
}

Detached triggers

createPreviewCardHandle() links triggers outside the PreviewCard root to it via the handle prop; each trigger passes a payload the card renders through function-as-children, so one card serves several links.

Plan sprint work with cycles and group feature work with modules.

Show code
import {
  createPreviewCardHandle,
  PreviewCard,
  PreviewCardBody,
  PreviewCardContent,
  PreviewCardDescription,
  PreviewCardTitle,
  PreviewCardTrigger,
} from "@makeplane/propel/components/preview-card";

const topics: Record<string, { title: string; description: string }> = {
  cycles: {
    title: "Cycles",
    description: "Time-boxed iterations for planning and shipping work in focused sprints.",
  },
  modules: {
    title: "Modules",
    description: "Larger bodies of related work items tracked together across cycles.",
  },
};

const topicPreviewCard = createPreviewCardHandle();

export default function DetachedTriggersDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <p className="max-w-prose text-body-sm-regular text-secondary">
        Plan sprint work with{" "}
        <PreviewCardTrigger
          handle={topicPreviewCard}
          payload="cycles"
          href="https://docs.plane.so/core-concepts/cycles"
        >
          cycles
        </PreviewCardTrigger>{" "}
        and group feature work with{" "}
        <PreviewCardTrigger
          handle={topicPreviewCard}
          payload="modules"
          href="https://docs.plane.so/core-concepts/modules"
        >
          modules
        </PreviewCardTrigger>
        .
      </p>
      <PreviewCard handle={topicPreviewCard}>
        {({ payload }) => {
          const topic = typeof payload === "string" ? topics[payload] : undefined;
          return (
            <PreviewCardContent sideOffset={8}>
              {topic === undefined ? null : (
                <PreviewCardBody>
                  <PreviewCardTitle>{topic.title}</PreviewCardTitle>
                  <PreviewCardDescription>{topic.description}</PreviewCardDescription>
                </PreviewCardBody>
              )}
            </PreviewCardContent>
          );
        }}
      </PreviewCard>
    </div>
  );
}

API Reference

PreviewCard

The preview-card Root — Base UI's context/state provider (renders no element of its own). A behavior-only role, so it lives in `components` (rules 1a, 2); the styled parts live in `elements/preview-card` (and shared `internal/` primitives) and are grafted onto Base UI behavior here.

PropTypeDefaultDescription
childrenReactNode | PayloadChildRenderFunction<unknown>The content of the preview card. This can be a regular React node or a render function that receives the `payload` of the active trigger.

PreviewCardTrigger

The link that opens the preview card on hover or focus. Renders an `<a>` by default; pass `render` to project the trigger onto your own element (e.g. an `AnchorButton` rendering an `<a>`). Maps 1:1 to `PreviewCard.Trigger`.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, PreviewCardTriggerState>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

PreviewCardContent

Convenience that composes the anchored preview card boilerplate — Base UI portal, positioner, and popup grafted onto Propel's styled surface — so a consumer only writes the trigger and the card body. Pass `side`/`sideOffset`/`align` through to the positioner. No backdrop: unlike Dialog/AlertDialog/Drawer, a preview card is a non-modal, hover-triggered rich tooltip (it opens on hover/focus and closes on pointer-leave/blur, with no focus trap) — dimming the page behind a hover preview reads as a modal takeover it isn't. Base UI ships a `PreviewCard.Backdrop` primitive, but it is purely optional/decorative for apps that want one; it has none of `Dialog.Backdrop`'s click-outside-to-dismiss semantics, so omitting it costs no behavior.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.
side"top" | "bottom" | "left" | "right" | "inline-end" | "inline-start"bottomWhich side of the trigger the card opens toward.
sideOffsetnumber | OffsetFunction4Distance in px between the trigger and the card.
align"center" | "start" | "end"centerAlignment of the card relative to the trigger along `side`.
alignOffsetnumber | OffsetFunction0Additional offset in px along the align axis — slides the card along the trigger's edge without changing `align`.
collisionPaddingPadding5Minimum gap in px between the card and the viewport edge it would otherwise touch.
collisionBoundaryBoundaryThe element or rect the card is confined to when avoiding collisions. Base UI's clipping ancestors when unset.
collisionAvoidanceCollisionAvoidanceWhat to do when the card would overflow the boundary, set per axis: flip to the other side, shift along it, or stay put. Base UI flips the side and shifts the alignment when unset.
stickybooleanfalseKeep the card on screen after the trigger scrolls out of view, instead of following it out.
positionMethod"absolute" | "fixed""absolute"Which CSS `position` the positioner uses. `fixed` escapes overflow-clipping ancestors, e.g. a trigger inside a sticky header.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | nullPosition against this element instead of the trigger — an element, a ref, a virtual element, or a function returning one. Geometry only; the trigger keeps its role and interactions.
disableAnchorTrackingbooleanfalseStop tracking layout shifts of the anchor — position once, on open.

PreviewCardBody

The text content area of the card — typically holds a `PreviewCardTitle` and `PreviewCardDescription` stacked in a column. Owns the padding so a full-bleed `PreviewCardImage` can sit edge-to-edge above it; both the column layout and the padding are "always the same" per the design spec.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

PreviewCardTitle

The card's primary heading — `text-body-sm-medium` per the design spec. Truncates to a single line instead of wrapping (Figma titles are nowrap on the 296px card). Diverges from the shared `internal/overlay-title` "md" step (`semibold`) — that step has no current consumer (this family used to adopt it) and is lighter `medium` here per Figma, so it keeps its own recipe (rule 4a: divergent styling stays per-family). When `children` is a string, bakes a native `title` so hover recovers the full text past the truncation; pass `title` explicitly to override (or `title=""` to suppress).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

PreviewCardDescription

Supporting description text beneath the title — the shared `internal/overlay-description` recipe at the preview card's `md` size (13px secondary text). The size is fixed here (a `components` default, rule 13).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

PreviewCardImage

The thumbnail image shown inside the popup. Bakes in `w-full`, overflow-hidden, and object-cover so the thumbnail always spans the card, clips, and fills its box — these are "always the same" per the design spec. Height is set by the consumer's layout (native `height`, or the image's intrinsic aspect ratio at full width).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

PreviewCardEyebrow

Lays an optional leading node beside text on one row. The slot is unstyled flex — pass an icon, a 16×16 filled type indicator (Figma's issue frame), or any other leading glyph. Stories use the shared `internal/Icon` at `tint="secondary" size="md"` as a stand-in. Issue cards put a `PreviewCardEyebrowLabel` identifier (an issue key) here ABOVE a separate `PreviewCardTitle`. Cycle / module / release / intake cards put `PreviewCardTitle` in this row and skip `PreviewCardEyebrowLabel` — those canvases have no muted identifier. Compose it only when the card has a leading glyph; a card without one renders `PreviewCardTitle` directly inside `PreviewCardBody`.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

PreviewCardEyebrowLabel

The single-line identifier text inside a `PreviewCardEyebrow` (an issue key, …). Muted one step further than the description and truncates instead of wrapping. Not the card's heading — cycle / module / release / intake names belong in `PreviewCardTitle` (optionally inside the eyebrow, beside the icon). When `children` is a string, bakes a native `title` so hover recovers the full text past the truncation; pass `title` explicitly to override (or `title=""` to suppress).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

PreviewCardPropertyGroup

The row of property chips (status, priority, assignee, labels, …) shown beneath the description. A bare flex-wrap row — the chips are the consumer's own components (`PillButton`, `Avatar`, `Badge`, …); this part only supplies the row layout. Never `aria-hidden`: its content is meaningful.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

PreviewCardMeta

The muted footer caption closing out the card's text content (a source domain, a relative timestamp, …).

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.