Signature
An animated SVG signature effect that draws out text as if hand-written.
Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.
A card deck with weight — drag the top card past the threshold and it flies off with a spin, then slips back under the pile.
MSource LandMellow UIWhy it stands out
A card deck with weight — drag the top card past the threshold and it flies off with a spin, then slips back under the pile.
You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11 (install: `pnpm add motion`)
If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.
Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.
Copy-paste these files:
File location: components/toss-deck-demo.tsx
File content: "use client";
import React, { useEffect, useState } from "react";
import { TossDeck } from "../mellow/toss-deck";
const CARDS = [
{
title: "Study in balance",
discipline: "Direction",
caption: "Weight distributed evenly across the frame.",
meta: "Plate I · Bergen",
image: "https://picsum.photos/id/1048/640/432",
},
{
title: "The quiet studio",
discipline: "Studio",
caption: "Morning light on an unhurried bench.",
meta: "Plate II · Copenhagen",
image: "https://picsum.photos/id/1060/640/432",
},
{
title: "Measured movement",
discipline: "Motion",
caption: "Velocity held at the edge of stillness.",
meta: "Plate III · Zürich",
image: "https://picsum.photos/id/338/640/432",
},
{
title: "Material language",
discipline: "Identity",
caption: "Grain, pressure, and the record of touch.",
meta: "Plate IV · Milan",
image: "https://picsum.photos/id/225/640/432",
},
{
title: "A gathering of forms",
discipline: "Culture",
caption: "Objects arranged as a single sentence.",
meta: "Plate V · Lisbon",
image: "https://picsum.photos/id/537/640/432",
},
];
/** The docs preview box is ~340px wide on phones — shrink to fit it. */
function useNarrow() {
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 640px)");
const sync = () => setNarrow(mq.matches);
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
return narrow;
}
export default function TossDeckDemo() {
const narrow = useNarrow();
return (
<div className="flex flex-col items-center gap-4 px-3 py-4 sm:gap-6 sm:px-6 sm:py-10">
<TossDeck
width={narrow ? 236 : 320}
height={narrow ? 244 : 440}
items={CARDS.map((c, index) => (
<figure
key={c.title}
className="flex h-full select-none flex-col p-3"
>
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[0.8rem] bg-[rgba(var(--ink-rgb),0.06)]">
<img
src={c.image}
alt={c.title}
draggable={false}
className="absolute inset-0 h-full w-full scale-[1.04] object-cover saturate-[0.88] contrast-[1.04]"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(var(--background-rgb),0.02),rgba(var(--ink-rgb),0.28))]" />
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 h-14 bg-[linear-gradient(180deg,rgba(var(--background-rgb),0.78),transparent)]"
/>
<div className="absolute inset-x-0 top-0 flex items-center justify-between p-3 [font-family:var(--font-mono)] text-[0.5625rem] font-medium tracking-[0.16em] text-[var(--ink)] uppercase">
<span>{c.discipline}</span>
<span>{String(index + 1).padStart(2, "0")}</span>
</div>
</div>
<figcaption className="flex shrink-0 flex-col gap-2 px-2 pt-4 pb-1">
<div className="[font-family:var(--font-serif)] text-[1.35rem] leading-[1.08] text-[var(--ink)] italic">
{c.title}
</div>
<p className="m-0 [font-family:var(--font-sans)] text-[0.8125rem] leading-relaxed text-[rgba(var(--ink-rgb),0.58)]">
{c.caption}
</p>
<div className="mt-1 border-t border-[var(--rule)] pt-3 [font-family:var(--font-mono)] text-[0.5rem] tracking-[0.14em] text-[rgba(var(--ink-rgb),0.42)] uppercase">
{c.meta}
</div>
</figcaption>
</figure>
))}
/>
<p className="[font-family:var(--font-mono)] text-[0.5625rem] tracking-[0.12em] text-[rgba(var(--ink-rgb),0.35)] uppercase">
Drag to toss · arrow keys to browse
</p>
</div>
);
}
File location: components/mellow/toss-deck.tsx
File content: "use client";
import React, { useState } from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useTransform,
type PanInfo,
} from "motion/react";
export interface TossDeckProps {
/** Card faces, front of deck first. */
items: React.ReactNode[];
/** Card size in px. */
width?: number;
height?: number;
/** Cards visible behind the top card. */
peek?: number;
/** Show the 01 / 05 counter under the deck. */
counter?: boolean;
/** Called with the item index that lands on top after a toss. */
onChange?: (index: number) => void;
className?: string;
style?: React.CSSProperties;
}
/**
* A card deck with weight — drag the top card past the threshold (or press
* an arrow key) and it flies off with a spin, then slips back under the pile.
*/
export function TossDeck({
items,
width = 320,
height = 420,
peek = 2,
counter = true,
onChange,
className,
style,
}: TossDeckProps) {
const [order, setOrder] = useState(() => items.map((_, i) => i));
const [fly, setFly] = useState<{ key: number; dir: 1 | -1 } | null>(null);
const reduced = useReducedMotion();
const n = items.length;
const toss = (dir: 1 | -1) => {
if (fly || n < 2) return;
setFly({ key: order[0], dir });
};
const completeToss = () => {
const next = [...order.slice(1), order[0]];
setOrder(next);
setFly(null);
onChange?.(next[0]);
};
return (
<div
className={["flex flex-col items-center gap-5", className]
.filter(Boolean)
.join(" ")}
style={style}
>
<div
role="group"
aria-roledescription="card deck"
aria-label={`Card ${order[0] + 1} of ${n} — drag the card or use arrow keys`}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
toss(-1);
}
if (e.key === "ArrowRight") {
e.preventDefault();
toss(1);
}
}}
className="relative outline-none focus-visible:ring-2 focus-visible:ring-[rgba(var(--ink-rgb),0.3)]"
style={{ width, height: height + peek * 10 }}
>
{order.map((itemIdx, pos) => (
<DeckCard
key={itemIdx}
pos={pos}
n={n}
peek={peek}
width={width}
height={height}
flying={fly?.key === itemIdx ? fly.dir : 0}
isTop={pos === 0 && !fly}
onToss={toss}
onFlown={completeToss}
reduced={!!reduced}
>
{items[itemIdx]}
</DeckCard>
))}
</div>
{counter && (
<span className="[font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-[rgba(var(--ink-rgb),0.4)] uppercase tabular-nums">
{String(order[0] + 1).padStart(2, "0")} /{" "}
{String(n).padStart(2, "0")}
</span>
)}
</div>
);
}
function DeckCard({
children,
pos,
n,
peek,
width,
height,
flying,
isTop,
onToss,
onFlown,
reduced,
}: {
children: React.ReactNode;
pos: number;
n: number;
peek: number;
width: number;
height: number;
flying: 0 | 1 | -1;
isTop: boolean;
onToss: (dir: 1 | -1) => void;
onFlown: () => void;
reduced: boolean;
}) {
const x = useMotionValue(0);
const dragRotate = useTransform(
x,
[-width * 0.5, width * 0.5],
[-5, 5],
);
// Cards deeper than `peek` park exactly under the last visible slot instead
// of fading out, so a tossed card never vanishes — it just tucks under.
const depth = Math.min(pos, peek);
const fan = depth === 0 ? 0 : (depth % 2 === 1 ? 1 : -1) * depth * 1.8;
const stackX = depth === 0 ? 0 : (depth % 2 === 1 ? -1 : 1) * depth * 6;
const stackY = depth * 10 + (depth % 2 === 1 ? 3 : 0);
const slotSpring = reduced
? ({ duration: 0 } as const)
: ({ type: "spring", stiffness: 360, damping: 34, mass: 0.8 } as const);
const handleDragEnd = (_: unknown, info: PanInfo) => {
const past =
Math.abs(info.offset.x) > width * 0.24 ||
Math.abs(info.velocity.x) > 500;
if (!past) return;
const dir =
Math.abs(info.velocity.x) > 500
? Math.sign(info.velocity.x)
: Math.sign(info.offset.x);
onToss(dir >= 0 ? 1 : -1);
};
return (
<motion.div
animate={{
scale: 1 - depth * 0.025,
x: stackX,
y: stackY,
rotate: fan,
}}
transition={slotSpring}
className="absolute inset-x-0 top-0"
style={{ height, zIndex: flying !== 0 ? n + 1 : n - pos }}
>
<motion.div
drag={isTop && !reduced ? "x" : false}
dragConstraints={{ left: -width * 0.52, right: width * 0.52 }}
dragElastic={0.12}
dragMomentum={false}
dragSnapToOrigin
onDragEnd={isTop ? handleDragEnd : undefined}
animate={
flying !== 0
? { x: flying * (width * 0.92), y: -10 }
: { x: 0, y: 0 }
}
transition={
flying !== 0
? {
duration: reduced ? 0 : 0.26,
ease: [0.3, 0.8, 0.4, 1],
}
: slotSpring
}
onAnimationComplete={() => {
if (flying !== 0) onFlown();
}}
style={{ x, rotate: dragRotate }}
className={[
"relative h-full w-full overflow-hidden rounded-[1.25rem] border border-[var(--rule)] bg-[var(--background)] shadow-[0_24px_50px_-24px_color-mix(in_srgb,light-dark(var(--ink),var(--background))_42%,transparent)]",
isTop ? "cursor-grab active:cursor-grabbing" : "",
]
.filter(Boolean)
.join(" ")}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[linear-gradient(145deg,rgba(var(--background-rgb),0)_55%,rgba(var(--ink-rgb),0.045))]"
/>
{children}
</motion.div>
</motion.div>
);
}
export default TossDeck;
Usage notes:
Add a TossDeck component from the mellow library — a draggable card stack for testimonials or portfolios. The top card follows the cursor with a tilt; drag it past a third of its width (or flick it, or press an arrow key) and it flies off with a spin, then restacks at the back with springs. Pass `items` as an array of ReactNode card faces and size them with `width` / `height`. `peek` controls how many cards fan out behind, `counter` shows the 01 / 05 readout, `onChange` reports the index that lands on top. Fully keyboard accessible and respects prefers-reduced-motion.Discovery vocabulary
Related by governed terms
An animated SVG signature effect that draws out text as if hand-written.
More from Mellow UI