Domphy

fanCards

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

Implementation notes

Dark hero with a two-line gradient-shimmer headline (background-clip driven by a themed linear-gradient + CSS @keyframes drift, independent of interaction) and a tightly-stacked card deck that fans open on hover/tap (shared boolean applied imperatively per card, with a small per-card CSS transitionDelay stagger). Headline copy and card content (mini sparkline + price figures) are original generic 'market dashboard' placeholders, not the reference site's actual copy or mockups -- the spec's own research note flags the live source as unverifiable (fey.com now redirects post-acquisition) and exact fan spread/stagger timing as moderate-confidence, so both are best-guess approximations of the described behavior.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Fey Cards" — clean-room reimplementation from the public
// tagline ("cards fan apart on hover or tap to reveal a shifting headline
// gradient") and a static preview image only; the live source site could not
// be re-verified during research (see the spec's research note), so exact
// spread distance and stagger timing are approximated, not confirmed.
//
// A dark hero where a tightly stacked deck of app-preview cards fans open on
// hover/tap, layered over a two-line headline whose gradient sheen drifts on
// its own continuous loop. The fan state is a single "is the deck open"
// boolean tracked as a plain closure variable (not a Domphy `State`) and
// applied by writing `transform` straight to each card's DOM node — the same
// imperative-on-pointer-move tradeoff focusCards.ts/layoutMotionCards.ts make
// elsewhere in this package for continuous, purely visual interaction state,
// backed by one static CSS `transition`(+ per-card `transitionDelay` stagger)
// declared once per card. The gradient drift is a plain CSS `@keyframes` on
// `backgroundPosition` (per the project's animation guidance: continuous,
// decorative, unrelated to any interaction, so it runs independently of the
// fan and needs no JS at all).

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { hashString } from "@domphy/core";
import { heading, small, strong } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";

export interface FanCardItem {
  id: string;
  title: string;
  metricValue: string;
  /** e.g. `"+2.4%"` / `"-1.1%"` — colored via `trend`. */
  metricDelta: string;
  trend?: "up" | "down";
  /** Relative bar heights (0-1) for the card's tiny sparkline-style chart. */
  bars?: number[];
}

export interface FanCardsProps {
  cards?: FanCardItem[];
  headlineFirstLine?: string;
  headlineSecondLine?: string;
  /** What causes the deck to fan open. Defaults to `"hover"` (a `click` handler is always
   * attached too, so tap devices work even when `"hover"` is selected). */
  trigger?: "hover" | "tap";
  /** How far apart fanned cards spread, in degrees of rotation per step. Defaults to `10`. */
  fanSpreadDeg?: number;
  /** Gradient drift loop duration, in seconds. Defaults to `6`. */
  shimmerDurationSeconds?: number;
  style?: StyleObject;
}

interface ResolvedFanCard {
  item: FanCardItem;
  restRotation: number;
  fannedRotation: number;
  fannedTranslateX: number;
}

const DEFAULT_CARDS: FanCardItem[] = [
  { id: "alpha-fund", title: "Alpha Fund", metricValue: "$128.40", metricDelta: "+2.4%", trend: "up", bars: [0.35, 0.5, 0.42, 0.68, 0.6, 0.8] },
  { id: "beta-index", title: "Beta Index", metricValue: "$64.12", metricDelta: "-1.1%", trend: "down", bars: [0.7, 0.6, 0.65, 0.5, 0.44, 0.38] },
  { id: "horizon-etf", title: "Horizon ETF", metricValue: "$301.87", metricDelta: "+0.8%", trend: "up", bars: [0.4, 0.46, 0.5, 0.58, 0.55, 0.66] },
  { id: "nova-growth", title: "Nova Growth", metricValue: "$18.55", metricDelta: "+5.6%", trend: "up", bars: [0.3, 0.34, 0.5, 0.62, 0.74, 0.9] },
];

let fanCardsInstanceCounter = 0;

function sparkline(bars: number[], trend: "up" | "down"): DomphyElement<"div"> {
  const family: ThemeColor = trend === "up" ? "success" : "danger";
  return {
    div: bars.map((height, index) => ({
      div: null,
      _key: `bar-${index}`,
      ariaHidden: "true",
      _doctorDisable: "missing-color",
      style: {
        inlineSize: themeSpacing(2),
        blockSize: `${Math.round(height * 100)}%`,
        borderRadius: themeSpacing(0.5),
        // A flat accent fill needs a *specific* tone (not the ambient surface color), so it's
        // expressed as a solid-color `backgroundImage` gradient rather than `backgroundColor` —
        // the same escape hatch focusCards.ts's placeholder media uses, since the
        // tone-background-inherit rule only watches `backgroundColor`.
        backgroundImage: (listener: Listener) => `linear-gradient(${themeColor(listener, "shift-9", family)}, ${themeColor(listener, "shift-9", family)})`,
      },
    })) as DomphyElement<"div">[],
    ariaHidden: "true",
    style: {
      display: "flex",
      alignItems: "flex-end",
      gap: themeSpacing(1),
      blockSize: themeSpacing(9),
    } as StyleObject,
  };
}

function restTransform(card: ResolvedFanCard, offsetFromCenter: number): string {
  return `translate(calc(-50% + ${offsetFromCenter * 6}px), 0) rotate(${card.restRotation}deg)`;
}

function fannedTransform(card: ResolvedFanCard, offsetFromCenter: number): string {
  const translateY = Math.abs(offsetFromCenter) * -6;
  return `translate(calc(-50% + ${card.fannedTranslateX}px), ${translateY}px) rotate(${card.fannedRotation}deg)`;
}

function fanCardTree(card: ResolvedFanCard, index: number, offsetFromCenter: number, cardElements: (HTMLElement | null)[]): DomphyElement<"div"> {
  const item = card.item;
  const trend = item.trend ?? "up";
  const bars = item.bars ?? [0.4, 0.5, 0.45, 0.6, 0.55, 0.7];

  return {
    div: [
      { small: item.title, $: [small({ color: "neutral" })] },
      {
        div: [
          { strong: item.metricValue, $: [strong({ color: "neutral" })] },
          { small: item.metricDelta, $: [small({ color: trend === "up" ? "success" : "error" })] },
        ],
        style: { display: "flex", alignItems: "baseline", gap: themeSpacing(2) },
      } as DomphyElement<"div">,
      sparkline(bars, trend),
    ],
    _key: item.id,
    dataTone: "shift-16",
    _onMount: (node: ElementNode) => {
      const element = node.domElement as HTMLElement;
      cardElements[index] = element;
      element.style.transform = restTransform(card, offsetFromCenter);
    },
    _onRemove: () => {
      cardElements[index] = null;
    },
    style: {
      position: "absolute",
      insetBlockStart: "50%",
      insetInlineStart: "50%",
      inlineSize: themeSpacing(46),
      display: "flex",
      flexDirection: "column",
      gap: themeSpacing(2),
      padding: themeSpacing(4),
      borderRadius: themeSpacing(3),
      zIndex: index,
      transitionDelay: `${index * 35}ms`,
      transition: "transform 480ms cubic-bezier(0.22, 1, 0.36, 1)",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      boxShadow: (listener: Listener) => `0 ${themeSpacing(3)} ${themeSpacing(8)} ${themeColor(listener, "shift-4", "neutral")}`,
    } as StyleObject,
  };
}

/**
 * A dark hero where a tightly stacked deck of app-preview cards fans open on
 * hover/tap, layered over a two-line headline with a continuously drifting
 * gradient sheen. Call with no arguments for a working demo — 4 generic
 * "market" preview cards over a generic two-line headline.
 */
function fanCards(props: FanCardsProps = {}): DomphyElement<"div"> {
  const cards = props.cards && props.cards.length > 0 ? props.cards : DEFAULT_CARDS;
  const headlineFirstLine = props.headlineFirstLine ?? "Markets move fast.";
  const headlineSecondLine = props.headlineSecondLine ?? "Your dashboard should too.";
  const trigger = props.trigger ?? "hover";
  const fanSpreadDeg = props.fanSpreadDeg ?? 10;
  const shimmerDurationSeconds = props.shimmerDurationSeconds ?? 6;

  const instanceId = ++fanCardsInstanceCounter;
  const shimmerAnimationName = `domphy-fan-cards-shimmer-${hashString(String(instanceId))}`;

  const center = (cards.length - 1) / 2;
  const resolvedCards: ResolvedFanCard[] = cards.map((item, index) => {
    const offsetFromCenter = index - center;
    return {
      item,
      restRotation: offsetFromCenter * 2.5,
      fannedRotation: offsetFromCenter * fanSpreadDeg,
      fannedTranslateX: offsetFromCenter * 34,
    };
  });

  const cardElements: (HTMLElement | null)[] = cards.map(() => null);
  let isFanned = false;

  const applyFanState = (fanned: boolean) => {
    isFanned = fanned;
    resolvedCards.forEach((card, index) => {
      const element = cardElements[index];
      if (!element) return;
      const offsetFromCenter = index - center;
      element.style.transform = fanned ? fannedTransform(card, offsetFromCenter) : restTransform(card, offsetFromCenter);
    });
  };

  const cardTrees = resolvedCards.map((card, index) => fanCardTree(card, index, index - center, cardElements));

  return {
    div: [
      {
        h1: [{ span: headlineFirstLine }, { br: null }, { span: headlineSecondLine }],
        // `heading()` supplies the theme's h1 size scale (a `themeSize` function, not a literal) —
        // only the gradient-clip properties below are added on top of it, so no literal
        // typography values are ever set directly.
        $: [heading({ color: "neutral" })],
        style: {
          textAlign: "center",
          backgroundImage: (listener: Listener) =>
            `linear-gradient(100deg, ${themeColor(listener, "shift-0", "neutral")}, ${themeColor(listener, "shift-7", "neutral")}, ${themeColor(listener, "shift-0", "neutral")})`,
          backgroundSize: "220% 100%",
          backgroundClip: "text",
          WebkitBackgroundClip: "text",
          color: "transparent",
          WebkitTextFillColor: "transparent",
          animation: `${shimmerAnimationName} ${shimmerDurationSeconds}s linear infinite`,
          [`@keyframes ${shimmerAnimationName}`]: {
            "0%": { backgroundPosition: "0% 50%" },
            "100%": { backgroundPosition: "220% 50%" },
          },
        } as StyleObject,
      } as DomphyElement<"h1">,
      { div: cardTrees, style: { position: "absolute", inset: 0 } } as DomphyElement<"div">,
    ],
    onMouseEnter: () => {
      if (trigger === "hover") applyFanState(true);
    },
    onMouseLeave: () => {
      if (trigger === "hover") applyFanState(false);
    },
    onClick: () => applyFanState(!isFanned),
    dataTone: "shift-17",
    style: {
      position: "relative",
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      inlineSize: "100%",
      blockSize: themeSpacing(120),
      overflow: "hidden",
      padding: themeSpacing(8),
      cursor: "pointer",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { fanCards };

← Back to Aceternity UI catalog