Domphy

draggableCard

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

Implementation notes

Full behavior match: Pointer Events (pointerdown declaratively, pointermove/pointerup on window for the drag's duration) write position/rotation directly to each card's inline left/top/transform; rotation is derived from instantaneous drag velocity and smoothed (lag toward target angle) for a natural tilt-while-moving feel; dragging is rubber-band-clamped to the bounding container; release runs an independent spring-damper simulation (same stiffness/damping/mass formula smoothCursor.ts uses) for position AND rotation, producing the spec's slight overshoot-then-settle. Each card's initial tilt is a deterministic pseudo-random angle seeded by index (generated once, never re-randomized).

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Draggable Card" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied).
// Polaroid-style photo cards, each at a fixed random tilt, that can be
// dragged around a bounded area with velocity-driven tilt while dragging and
// a spring settle (slight overshoot) on release.
//
// Drag is plain Pointer Events (`onPointerDown` declaratively, then
// `pointermove`/`pointerup` on `window` for the duration of the drag — the
// same "continuous, purely visual, cursor-following effect stays imperative"
// tradeoff smoothCursor.ts makes) writing directly to `left`/`top`/`transform`
// so the card tracks the cursor with zero lag while held. Only the release
// phase runs a spring-damper simulation (same formula as smoothCursor.ts's
// `step`, applied to position AND rotation independently) so the card
// coasts to rest with a small bounce instead of snapping.
//
// Each card's initial tilt is a deterministic pseudo-random angle derived
// from its index (`pseudoRandomAngle`), generated once and reused — never
// re-randomized on re-render, so cards don't visibly jump.

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

export interface DraggableCardItem {
  id: string;
  /** Photo source. Defaults to a themed gradient placeholder when omitted. */
  imageSrc?: string;
  caption: string;
}

export interface DraggableCardProps {
  cards?: DraggableCardItem[];
  onDragEnd?: (id: string, position: { x: number; y: number }) => void;
  style?: StyleObject;
}

interface SpringConfig {
  stiffness: number;
  damping: number;
  mass: number;
}

const POSITION_SPRING: SpringConfig = { stiffness: 260, damping: 24, mass: 1 };
const ROTATION_SPRING: SpringConfig = { stiffness: 220, damping: 18, mass: 1 };
const REST_DELTA = 0.01;
const HOVER_SCALE = 1.04;
const DRAG_SCALE = 1.06;
const MAX_DRAG_TILT_DEG = 22;
const TILT_LAG = 0.25; // fraction of the way the rendered angle catches up to the target angle each pointermove
const EDGE_RESISTANCE = 0.35; // fraction of out-of-bounds distance still applied while dragging (rubber-band)
const CARD_WIDTH_UNITS = 44;

const DEFAULT_CARDS: DraggableCardItem[] = [
  { id: "polaroid-1", caption: "Prague, 2024" },
  { id: "polaroid-2", caption: "Kyoto, 2023" },
  { id: "polaroid-3", caption: "Lisbon, 2022" },
];

const PLACEHOLDER_COLORS: ThemeColor[] = ["primary", "secondary", "info", "success", "attention"];

/** Deterministic pseudo-random angle in [-12, 12] degrees, stable per `seed`. */
function pseudoRandomAngle(seed: number): number {
  const fractional = Math.sin(seed * 12.9898) * 43758.5453;
  const unitInterval = fractional - Math.floor(fractional);
  return (unitInterval - 0.5) * 24;
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

/** Rubber-band clamp: fully free within [min, max], increasingly resistant beyond it. */
function clampWithResistance(value: number, min: number, max: number): number {
  if (value < min) return min - (min - value) * (1 - EDGE_RESISTANCE);
  if (value > max) return max + (value - max) * (1 - EDGE_RESISTANCE);
  return value;
}

function placeholderPhoto(index: number): DomphyElement<"div"> {
  const familyColor = PLACEHOLDER_COLORS[index % PLACEHOLDER_COLORS.length];
  const element = {
    div: null,
    ariaHidden: "true",
    _doctorDisable: "missing-color",
    style: {
      width: "100%",
      aspectRatio: "1 / 1",
      borderRadius: themeSpacing(1),
      backgroundImage: (listener: Listener) =>
        `linear-gradient(135deg, ${themeColor(listener, "shift-6", familyColor)}, ${themeColor(listener, "shift-11", familyColor)})`,
    },
  };
  return element as DomphyElement<"div">;
}

/**
 * Polaroid-style photo cards, each with a fixed random tilt, freely
 * draggable within a bounded area and released with a small spring bounce.
 * Call with no arguments for a working demo — 3 generic placeholder photos.
 */
function draggableCard(props: DraggableCardProps = {}): DomphyElement<"div"> {
  const cards = props.cards && props.cards.length > 0 ? props.cards : DEFAULT_CARDS;

  let containerElement: HTMLElement | null = null;
  const cardElements: (HTMLElement | null)[] = cards.map(() => null);
  const baseAngles = cards.map((_item, index) => pseudoRandomAngle(index + 1));
  const angles = [...baseAngles];
  const positions = cards.map(() => ({ x: 0, y: 0 }));
  const dragging = cards.map(() => false);
  const hovered = cards.map(() => false);
  const settleFrameHandles: (number | null)[] = cards.map(() => null);

  const applyTransform = (index: number) => {
    const element = cardElements[index];
    if (!element) return;
    const scale = dragging[index] ? DRAG_SCALE : hovered[index] ? HOVER_SCALE : 1;
    element.style.left = `${positions[index].x}px`;
    element.style.top = `${positions[index].y}px`;
    element.style.transform = `rotate(${angles[index].toFixed(2)}deg) scale(${scale})`;
    element.style.zIndex = dragging[index] ? String(100 + index) : String(index);
  };

  const stopSettleLoop = (index: number) => {
    const handle = settleFrameHandles[index];
    if (handle !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(handle);
    settleFrameHandles[index] = null;
  };

  /** Spring-damper release: eases position toward `(targetX, targetY)` and rotation toward `targetAngle`, with a slight overshoot before settling. */
  const startSettleSpring = (index: number, targetX: number, targetY: number, targetAngle: number) => {
    stopSettleLoop(index);
    let velocityX = 0;
    let velocityY = 0;
    let velocityAngle = 0;
    let lastTime = typeof performance !== "undefined" ? performance.now() : Date.now();

    const step = (time: number) => {
      const deltaSeconds = Math.min((time - lastTime) / 1000, 1 / 30);
      lastTime = time;

      const positionX = positions[index].x;
      const positionY = positions[index].y;
      const accelerationX = (-POSITION_SPRING.stiffness * (positionX - targetX) - POSITION_SPRING.damping * velocityX) / POSITION_SPRING.mass;
      const accelerationY = (-POSITION_SPRING.stiffness * (positionY - targetY) - POSITION_SPRING.damping * velocityY) / POSITION_SPRING.mass;
      velocityX += accelerationX * deltaSeconds;
      velocityY += accelerationY * deltaSeconds;
      positions[index] = { x: positionX + velocityX * deltaSeconds, y: positionY + velocityY * deltaSeconds };

      const accelerationAngle =
        (-ROTATION_SPRING.stiffness * (angles[index] - targetAngle) - ROTATION_SPRING.damping * velocityAngle) / ROTATION_SPRING.mass;
      velocityAngle += accelerationAngle * deltaSeconds;
      angles[index] += velocityAngle * deltaSeconds;

      applyTransform(index);

      const settled =
        Math.abs(targetX - positions[index].x) < REST_DELTA &&
        Math.abs(targetY - positions[index].y) < REST_DELTA &&
        Math.abs(targetAngle - angles[index]) < REST_DELTA &&
        Math.hypot(velocityX, velocityY) < REST_DELTA &&
        Math.abs(velocityAngle) < REST_DELTA;

      if (settled) {
        positions[index] = { x: targetX, y: targetY };
        angles[index] = targetAngle;
        applyTransform(index);
        settleFrameHandles[index] = null;
        return;
      }
      settleFrameHandles[index] = requestAnimationFrame(step);
    };
    settleFrameHandles[index] = requestAnimationFrame(step);
  };

  const startDrag = (event: PointerEvent, index: number) => {
    if (!containerElement || typeof window === "undefined") return;
    event.preventDefault();
    stopSettleLoop(index);
    dragging[index] = true;
    applyTransform(index);

    const cardElement = cardElements[index];
    const containerRect = containerElement.getBoundingClientRect();
    // `getBoundingClientRect()` can transiently report an all-zero rect (not
    // yet laid out, or a headless/test DOM with no real layout engine) — fall
    // back to `clientWidth`/`clientHeight` rather than collapsing the drag
    // bounds to a single point.
    const containerWidth = containerRect.width || containerElement.clientWidth || 480;
    const containerHeight = containerRect.height || containerElement.clientHeight || 320;
    const cardWidth = cardElement?.offsetWidth || 176;
    const cardHeight = cardElement?.offsetHeight || 220;
    const minX = 0;
    const minY = 0;
    const maxX = Math.max(0, containerWidth - cardWidth);
    const maxY = Math.max(0, containerHeight - cardHeight);

    const startPointerX = event.clientX;
    const startPointerY = event.clientY;
    const startX = positions[index].x;
    const startY = positions[index].y;
    let lastX = startX;
    let lastTime = typeof performance !== "undefined" ? performance.now() : Date.now();

    const handleMove = (moveEvent: PointerEvent) => {
      const rawX = startX + (moveEvent.clientX - startPointerX);
      const rawY = startY + (moveEvent.clientY - startPointerY);
      const now = typeof performance !== "undefined" ? performance.now() : Date.now();
      const deltaTime = Math.max(1, now - lastTime);
      const instantVelocityX = ((rawX - lastX) / deltaTime) * 16; // approx px per animation frame
      lastX = rawX;
      lastTime = now;

      positions[index] = {
        x: clampWithResistance(rawX, minX, maxX),
        y: clampWithResistance(rawY, minY, maxY),
      };
      const targetAngle = baseAngles[index] + clamp(instantVelocityX * 1.6, -MAX_DRAG_TILT_DEG, MAX_DRAG_TILT_DEG);
      angles[index] += (targetAngle - angles[index]) * TILT_LAG;
      applyTransform(index);
    };

    const handleUp = () => {
      window.removeEventListener("pointermove", handleMove);
      window.removeEventListener("pointerup", handleUp);
      dragging[index] = false;
      const clampedX = clamp(positions[index].x, minX, maxX);
      const clampedY = clamp(positions[index].y, minY, maxY);
      startSettleSpring(index, clampedX, clampedY, baseAngles[index]);
      props.onDragEnd?.(cards[index].id, { x: clampedX, y: clampedY });
    };

    window.addEventListener("pointermove", handleMove);
    window.addEventListener("pointerup", handleUp);
  };

  const cardTrees: DomphyElement<"div">[] = cards.map((item, index) => {
    const media = item.imageSrc
      ? ({
          img: null,
          src: item.imageSrc,
          alt: item.caption,
          style: { width: "100%", aspectRatio: "1 / 1", objectFit: "cover", display: "block", borderRadius: themeSpacing(1) },
        } as DomphyElement<"img">)
      : placeholderPhoto(index);

    return {
      div: [media, { small: item.caption, $: [small({ color: "neutral" })] }],
      _key: item.id,
      ariaLabel: item.caption,
      dataTone: "shift-1",
      onPointerDown: (event: PointerEvent) => startDrag(event, index),
      onMouseEnter: () => {
        hovered[index] = true;
        if (!dragging[index]) applyTransform(index);
      },
      onMouseLeave: () => {
        hovered[index] = false;
        if (!dragging[index]) applyTransform(index);
      },
      _onMount: (node: ElementNode) => {
        const element = node.domElement as HTMLElement;
        cardElements[index] = element;
        // Container mounts before its children (see lens.ts's note on mount
        // ordering), so `containerElement` is already assigned by now — scatter
        // this card into an initial stacked position using its own now-available
        // rendered size.
        const containerWidth = containerElement?.clientWidth || 480;
        const containerHeight = containerElement?.clientHeight || 320;
        const cardWidth = element.offsetWidth || 176;
        const cardHeight = element.offsetHeight || 220;
        const stackStep = (index - (cards.length - 1) / 2) * 28;
        positions[index] = {
          x: clamp((containerWidth - cardWidth) / 2 + stackStep, 0, Math.max(0, containerWidth - cardWidth)),
          y: clamp((containerHeight - cardHeight) / 2 + stackStep * 0.6, 0, Math.max(0, containerHeight - cardHeight)),
        };
        applyTransform(index);
      },
      _onRemove: () => {
        stopSettleLoop(index);
        cardElements[index] = null;
      },
      style: {
        position: "absolute",
        insetBlockStart: 0,
        insetInlineStart: 0,
        width: themeSpacing(CARD_WIDTH_UNITS),
        padding: themeSpacing(2),
        paddingBlockEnd: themeSpacing(5),
        borderRadius: themeSpacing(1),
        cursor: "grab",
        touchAction: "none",
        userSelect: "none",
        backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
        color: (listener: Listener) => themeColor(listener, "shift-9"),
        boxShadow: (listener: Listener) => `0 ${themeSpacing(3)} ${themeSpacing(6)} ${themeColor(listener, "shift-4", "neutral")}`,
        willChange: "transform, left, top",
      } as StyleObject,
    } as DomphyElement<"div">;
  });

  return {
    div: cardTrees,
    dataTone: "shift-1",
    _onMount: (node: ElementNode) => {
      containerElement = node.domElement as HTMLElement;
    },
    _onRemove: () => {
      containerElement = null;
      cards.forEach((_item, index) => stopSettleLoop(index));
    },
    style: {
      position: "relative",
      height: themeSpacing(88),
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { draggableCard };

← Back to Aceternity UI catalog