Domphy

linkPreview

A Overlays block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call linkPreview() with no arguments for a working demo, or edit the code below live.

Implementation notes

Hover/focus-triggered floating card with opacity+scale+translateY enter/exit (~150ms). No hard dependency on a screenshot API: accepts imageSrc (static), an async/sync imageResolver(url), or falls back to a generic placeholder. Uses simple 'centered above trigger' absolute positioning instead of @domphy/ui's popover()/@domphy/floating, since popover's permanently-mounted visibility-toggle content can't be CSS-transitioned (would swallow the fade) and @domphy/floating isn't a package dependency of @domphy/blocks — no viewport-edge auto-flip, appropriate for a small short-lived card per the spec.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Link Preview" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). Wraps a
// link label so hovering (or focusing) it pops up a small floating
// thumbnail card previewing the destination page.
//
// Rather than reaching for `@domphy/ui`'s `popover()` primitive, this file
// owns its own tiny floating panel directly, mirroring `directionAwareHover`'s
// "manual DOM refs + inline transitions" idiom: `popover()`'s floating
// content stays permanently mounted and toggles a `visibility` CSS property
// on show/hide, which — unlike `opacity`/`transform` — cannot be
// CSS-transitioned, so it would swallow the spec's fade/scale/translate
// enter-exit entirely. Positioning here is a simple "centered above the
// trigger" absolute offset (no `@domphy/floating` viewport-flip logic,
// which isn't a dependency of this package) — appropriate for a small,
// short-lived preview card, not a full popover.
//
// No hard dependency on a third-party screenshot API (per the spec's
// research note): the preview image is either a caller-supplied static
// `imageSrc`, or resolved once via a caller-supplied `imageResolver(url)`
// (sync or async), or falls back to a generic placeholder graphic.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { link, skeleton } from "@domphy/ui";
import { themeColor, themeSpacing } from "@domphy/theme";

export interface LinkPreviewProps {
  /** Destination URL for the wrapped link. Defaults to a generic demo URL. */
  url?: string;
  /** Visible link label content. Defaults to the bare `url`. */
  children?: DomphyElement | DomphyElement[] | string;
  /** Preview card width, in px. Defaults to `200`. */
  width?: number;
  /** Preview card height, in px. Defaults to `125`. */
  height?: number;
  /** When `true`, uses `imageSrc` directly with no async resolution. Defaults to `false`. */
  isStatic?: boolean;
  /** Static preview image override — required when `isStatic` is `true`, optional fallback otherwise. */
  imageSrc?: string;
  /**
   * Async or sync resolver producing a preview image URL for `url`. Called once,
   * lazily, on first hover/focus. Ignored when `isStatic` is `true`.
   */
  imageResolver?: (destinationUrl: string) => string | Promise<string>;
  /** Passthrough style merged onto the outer trigger wrapper. */
  style?: StyleObject;
}

const PLACEHOLDER_PREVIEW_MARKUP =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 125">' +
  '<rect width="200" height="125" fill="#e2e8f0"/>' +
  '<rect x="14" y="14" width="90" height="10" rx="5" fill="#94a3b8"/>' +
  '<rect x="14" y="34" width="150" height="8" rx="4" fill="#cbd5e1"/>' +
  '<rect x="14" y="50" width="120" height="8" rx="4" fill="#cbd5e1"/>' +
  '<circle cx="164" cy="90" r="22" fill="#94a3b8"/>' +
  "</svg>";
const PLACEHOLDER_PREVIEW_URI = `data:image/svg+xml,${encodeURIComponent(PLACEHOLDER_PREVIEW_MARKUP)}`;

const ENTERED_TRANSFORM = "translate(-50%, 0) scale(1)";
const EXITED_TRANSFORM = "translate(-50%, 6px) scale(0.9)";

let linkPreviewInstanceCounter = 0;

/**
 * Wraps a link label so hovering/focusing it pops up a small floating
 * thumbnail card previewing the destination page. Call with no arguments for
 * a working demo with a generic placeholder preview.
 */
function linkPreview(props: LinkPreviewProps = {}): DomphyElement<"span"> {
  const instanceId = ++linkPreviewInstanceCounter;
  const url = props.url ?? "https://example.com";
  const labelContent: DomphyElement | DomphyElement[] = Array.isArray(props.children)
    ? props.children
    : typeof props.children === "string"
      ? [{ span: props.children } as DomphyElement<"span">]
      : props.children
        ? [props.children]
        : [{ span: url } as DomphyElement<"span">];
  const cardWidth = Math.max(80, props.width ?? 200);
  const cardHeight = Math.max(60, props.height ?? 125);
  const isStatic = props.isStatic ?? false;
  const initialImageSrc = isStatic ? (props.imageSrc ?? PLACEHOLDER_PREVIEW_URI) : (props.imageSrc ?? null);

  const openState = toState(false, `link-preview-open-${instanceId}`);
  const imageSrcState = toState<string | null>(initialImageSrc, `link-preview-src-${instanceId}`);
  const loadingState = toState(!initialImageSrc, `link-preview-loading-${instanceId}`);

  let resolveStarted = false;

  const previewImageLayer: DomphyElement<"img"> = {
    img: null,
    src: (listener: Listener) => imageSrcState.get(listener) ?? PLACEHOLDER_PREVIEW_URI,
    alt: "",
    ariaHidden: "true",
    style: {
      position: "absolute",
      inset: 0,
      width: "100%",
      height: "100%",
      display: (listener: Listener) => (loadingState.get(listener) ? "none" : "block"),
      objectFit: "cover",
    } as StyleObject,
  } as DomphyElement<"img">;

  const previewLoadingLayer: DomphyElement<"div"> = {
    div: null,
    $: [skeleton({ color: "neutral" })],
    style: {
      position: "absolute",
      inset: 0,
      display: (listener: Listener) => (loadingState.get(listener) ? "block" : "none"),
      height: "100%",
      borderRadius: 0,
    } as StyleObject,
  } as DomphyElement<"div">;

  const previewCard: DomphyElement<"div"> = {
    div: [previewImageLayer, previewLoadingLayer],
    role: "presentation",
    dataTone: "shift-17",
    style: {
      position: "absolute",
      insetBlockEnd: `calc(100% + ${themeSpacing(2)})`,
      insetInlineStart: "50%",
      width: `${cardWidth}px`,
      height: `${cardHeight}px`,
      overflow: "hidden",
      borderRadius: themeSpacing(3),
      pointerEvents: "none",
      opacity: 0,
      transform: EXITED_TRANSFORM,
      transformOrigin: "bottom center",
      transition: "opacity 150ms ease-out, transform 150ms ease-out",
      boxShadow: (listener: Listener) => `0 ${themeSpacing(2)} ${themeSpacing(6)} ${themeColor(listener, "shift-4")}`,
      outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
      outlineOffset: "-1px",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      const cardElement = node.domElement as HTMLElement | null;
      if (!cardElement) return;
      const release = openState.addListener((isOpen: boolean) => {
        cardElement.style.opacity = isOpen ? "1" : "0";
        cardElement.style.transform = isOpen ? ENTERED_TRANSFORM : EXITED_TRANSFORM;
      });
      node.setMetadata("linkPreviewOpenRelease", release);
    },
    _onRemove: (node: ElementNode) => {
      const release = node.getMetadata("linkPreviewOpenRelease") as (() => void) | undefined;
      release?.();
    },
  } as DomphyElement<"div">;

  const startResolveIfNeeded = () => {
    if (resolveStarted || isStatic || !props.imageResolver) return;
    resolveStarted = true;
    loadingState.set(true);
    Promise.resolve(props.imageResolver(url))
      .then((resolvedSrc) => {
        imageSrcState.set(resolvedSrc);
        loadingState.set(false);
      })
      .catch(() => {
        imageSrcState.set(PLACEHOLDER_PREVIEW_URI);
        loadingState.set(false);
      });
  };

  const show = () => {
    startResolveIfNeeded();
    openState.set(true);
  };
  const hide = () => openState.set(false);

  const triggerElement: DomphyElement<"a"> = {
    a: labelContent,
    href: url,
    target: "_blank",
    rel: "noopener noreferrer",
    $: [link({ color: "primary" })],
    onMouseEnter: show,
    onMouseLeave: hide,
    onFocus: show,
    onBlur: hide,
    onKeyDown: (event: Event) => {
      if ((event as KeyboardEvent).key === "Escape") hide();
    },
  } as DomphyElement<"a">;

  return {
    span: [triggerElement, previewCard],
    style: {
      position: "relative",
      display: "inline-block",
      ...(props.style ?? {}),
    } as StyleObject,
  } as DomphyElement<"span">;
}

export { linkPreview };

← Back to Aceternity UI catalog