# Pixel dissolve card default

A shadcn/ui block from the [@craftui](https://www.craftui.space) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/craftui/pixel-dissolve-card-default).

- Page, with a live preview: https://shadcn.exchange/r/craftui/pixel-dissolve-card-default
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/craftui/pixel-dissolve-card-default-light-9c7b426ad2e13176.png
- Registry item: https://www.craftui.space/r/pixel-dissolve-card-default.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.craftui.space/r/pixel-dissolve-card-default.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://www.craftui.space/r/craftui-base.json`

## Files

### `components/craftui/blocks/pixel-dissolve-card/default.tsx`

```tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

export type PixelDissolveSlide = {
  image: string;
  imageAlt?: string;
  heading: string;
};

export interface PixelDissolveCardProps {
  slides?: PixelDissolveSlide[];
  /** Grid resolution of the dissolve — `size` × `size` tiles. */
  size?: number;
  /** Seconds between each tile group vanishing. */
  revealStep?: number;
  className?: string;
}

const DEFAULT_SIZE = 14;
const DEFAULT_REVEAL_STEP = 0.045;
/** Tail added after the last group starts, so the timer outlives the animation. */
const SETTLE_S = 0.4;

const DEFAULT_SLIDES: PixelDissolveSlide[] = [
  {
    image:
      "https://images.unsplash.com/photo-1557672172-298e090bd0f1?q=80&w=900&auto=format&fit=crop",
    imageAlt: "Layered gradient light study",
    heading:
      "A moment where care flows freely between beings. An experience you are familiar with, but perhaps forgotten.",
  },
  {
    image:
      "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=900&auto=format&fit=crop",
    imageAlt: "Abstract fluid shapes in warm tones",
    heading:
      "A quiet signal beneath the noise. The shape of an idea before it has learned to speak its own name.",
  },
  {
    image:
      "https://images.unsplash.com/photo-1541701494587-cb58502866ab?q=80&w=900&auto=format&fit=crop",
    imageAlt: "Soft blue and pink light diffusion",
    heading:
      "Motion is memory in disguise. Every pixel that fades was once certain of its place in the whole. Perfectly imperfect.",
  },
];

type Tile = { x: number; y: number; delay: number };

function shuffle<T>(list: T[]): T[] {
  const result = [...list];
  for (let i = result.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [result[i], result[j]] = [result[j], result[i]];
  }
  return result;
}

/**
 * Assigns every cell to one of `size` groups — exactly `size` cells each, in
 * random positions — then gives each group its own delay. Tiles vanish in
 * scattered clusters rather than row by row.
 */
function buildTiles(size: number, revealStep: number): Tile[] {
  const groups: number[] = [];
  for (let g = 0; g < size; g++) for (let i = 0; i < size; i++) groups.push(g);
  const shuffled = shuffle(groups);

  const order = shuffle(Array.from({ length: size }, (_, k) => k));
  const delayOf = new Map<number, number>();
  order.forEach((g, idx) => delayOf.set(g, idx * revealStep));

  const tiles: Tile[] = [];
  let i = 0;
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      tiles.push({ x, y, delay: delayOf.get(shuffled[i++])! });
    }
  }
  return tiles;
}

const EASE_OUT_QUART = [0.165, 0.84, 0.44, 1] as const;
const EASE_OUT_QUAD = [0.25, 0.46, 0.45, 0.94] as const;

/** Per-word offset, so the line reads left to right as it resolves. */
const WORD_STAGGER_S = 0.02;
const WORD_DURATION_S = 0.2;
const HEADING_EXIT_S = 0.18;

export default function PixelDissolveCard({
  slides = DEFAULT_SLIDES,
  size = DEFAULT_SIZE,
  revealStep = DEFAULT_REVEAL_STEP,
  className = "",
}: PixelDissolveCardProps) {
  const [index, setIndex] = useState(0);
  // The image currently dissolving away; null while idle.
  const [outgoing, setOutgoing] = useState<{
    image: string;
    tiles: Tile[];
  } | null>(null);
  const timer = useRef<number | null>(null);
  const reduceMotion = useReducedMotion();

  useEffect(() => {
    return () => {
      if (timer.current !== null) window.clearTimeout(timer.current);
    };
  }, []);

  const next = useCallback(() => {
    if (outgoing) return; // ignore input mid-transition
    const from = slides[index];
    setIndex((i) => (i + 1) % slides.length);

    if (reduceMotion) return;

    setOutgoing({ image: from.image, tiles: buildTiles(size, revealStep) });
    timer.current = window.setTimeout(
      () => setOutgoing(null),
      (size * revealStep + SETTLE_S) * 1000,
    );
  }, [index, outgoing, reduceMotion, revealStep, size, slides]);

  const slide = slides[index];

  return (
    // `flex flex-col` overrides the button UA layout, which would otherwise
    // centre the contents vertically and float the image off the top edge.
    <button
      type="button"
      onClick={next}
      aria-label="Show next slide"
      className={`flex h-auto md:h-[550px] w-full max-w-[380px] cursor-pointer flex-col items-stretch overflow-hidden rounded-[18px] sm:rounded-[22px] bg-[#fbfbfa] p-0 text-left shadow-[0_20px_60px_-25px_rgba(0,0,0,0.25)] transition-transform duration-150 select-none active:scale-[0.98] ${className}`}
    >
      <div
        className="relative h-56 sm:h-72 md:h-80 shrink-0 overflow-hidden"
        style={{ ["--pixcard-size" as string]: size }}
      >
        {/* Incoming image sits underneath and is revealed as tiles drop away. */}
        <img
          className="absolute inset-0 h-full w-full object-cover"
          src={slide.image}
          alt={slide.imageAlt ?? ""}
        />

        {outgoing && (
          <div
            key={index}
            className="pixcard-grid absolute inset-0 z-[1] grid"
            aria-hidden="true"
          >
            {outgoing.tiles.map((t) => (
              <div
                key={`${t.x}-${t.y}`}
                className="pixcard-tile"
                style={{
                  backgroundImage: `url("${outgoing.image}")`,
                  backgroundPosition: `${(t.x / (size - 1)) * 100}% ${
                    (t.y / (size - 1)) * 100
                  }%`,
                  animationDelay: `${t.delay}s`,
                }}
              />
            ))}
          </div>
        )}
      </div>

      <div className="relative px-4 pt-5 pb-5 sm:px-6 sm:pt-6 md:px-[26px] md:pt-7 md:pb-[22px] text-[19px] sm:text-[22px] md:text-[27px] leading-[1.08] font-medium tracking-[-0.02em] text-[#131313]">
        <AnimatePresence mode="wait">
          <motion.h2
            key={index}
            exit={{ opacity: 0, y: -6 }}
            transition={{ duration: HEADING_EXIT_S, ease: EASE_OUT_QUAD }}
          >
            {slide.heading.split(" ").map((word, i) => (
              <span key={`${i}-${word}`}>
                <span className="inline-block overflow-hidden pb-[0.14em] align-bottom mb-[-0.14em] text-pretty text-left">
                  <motion.span
                    className="inline-block"
                    initial={reduceMotion ? false : { y: "105%" }}
                    animate={{ y: "0%" }}
                    transition={{
                      duration: WORD_DURATION_S,
                      delay: i * WORD_STAGGER_S,
                      ease: EASE_OUT_QUART,
                    }}
                  >
                    {word}
                  </motion.span>
                </span>{" "}
              </span>
            ))}
          </motion.h2>
        </AnimatePresence>
      </div>
    </button>
  );
}
```
