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 hand of cards fanned in an arc — hover one and it straightens and rises out of the fan while its neighbours part in sympathy.
MSource LandMellow UIWhy it stands out
A hand of cards fanned in an arc — hover one and it straightens and rises out of the fan while its neighbours part in sympathy.
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/card-fan-demo.tsx
File content: "use client";
import React, { useEffect, useState } from "react";
import { CardFan } from "../mellow/card-fan";
const CARDS = [
{ numeral: "I", name: "The Studio", image: "https://picsum.photos/id/1084/380/400" },
{ numeral: "II", name: "The Voyage", image: "https://picsum.photos/id/1036/380/400" },
{ numeral: "III", name: "The Archive", image: "https://picsum.photos/id/1050/380/400" },
{ numeral: "IV", name: "The Harbour", image: "https://picsum.photos/id/1053/380/400" },
{ numeral: "V", name: "The Season", image: "https://picsum.photos/id/1044/380/400" },
];
/** 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 CardFanDemo() {
const [drawn, setDrawn] = useState<number | null>(null);
const narrow = useNarrow();
return (
<div className="flex flex-col items-center gap-3 px-3 pt-6 pb-4 sm:gap-5 sm:px-6 sm:pt-12 sm:pb-8">
<CardFan
width={narrow ? 100 : 164}
height={narrow ? 140 : 232}
arc={narrow ? 34 : 52}
onSelect={setDrawn}
items={CARDS.map((c) => (
<div
key={c.numeral}
className="flex h-full w-full flex-col p-3 select-none"
>
<div className="flex items-baseline justify-between pb-2 [font-family:var(--font-serif)] text-sm text-[var(--ink)] italic">
<span>{c.numeral}</span>
<span className="[font-family:var(--font-mono)] text-[0.5rem] not-italic tracking-[0.16em] text-[rgba(var(--ink-rgb),0.4)] uppercase">
Mellow
</span>
</div>
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg bg-[rgba(var(--ink-rgb),0.06)]">
<img
src={c.image}
alt={c.name}
draggable={false}
className="absolute inset-0 h-full w-full object-cover saturate-[0.85] contrast-[1.05]"
/>
</div>
<div className="pt-3 pb-1 text-center [font-family:var(--font-serif)] text-base text-[var(--ink)] italic">
{c.name}
</div>
</div>
))}
/>
<p className="[font-family:var(--font-mono)] text-[0.5625rem] tracking-[0.12em] text-[rgba(var(--ink-rgb),0.35)] uppercase">
{drawn !== null
? `Drawn — ${CARDS[drawn].numeral} · ${CARDS[drawn].name}`
: "Hover to draw a card · click to keep it"}
</p>
</div>
);
}
File location: components/mellow/card-fan.tsx
File content: "use client";
import React, { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
export interface CardFanProps {
/** Card faces, left of the fan first. */
items: React.ReactNode[];
/** Card size in px. */
width?: number;
height?: number;
/** Total fan angle in degrees. */
arc?: number;
/** How far a hovered card rises out of the fan, in px. */
lift?: number;
/** Called with the card index on click or Enter. */
onSelect?: (index: number) => void;
className?: string;
style?: React.CSSProperties;
}
/**
* A hand of cards fanned in an arc — hover (or focus) one and it straightens
* and rises out of the fan while its neighbours part in sympathy, like a
* card drawn from a held hand.
*/
export function CardFan({
items,
width = 190,
height = 264,
arc = 52,
lift = 56,
onSelect,
className,
style,
}: CardFanProps) {
const [active, setActive] = useState<number | null>(null);
const reduced = useReducedMotion();
const n = items.length;
const radius = height * 1.6;
const step = n > 1 ? arc / (n - 1) : 0;
const baseAngle = (i: number) => (i - (n - 1) / 2) * step;
// pivot sits `radius` below the card's centre
const origin = `50% ${height / 2 + radius}px`;
// bounding box of the fanned edge cards, so the container never overflows
const half = ((arc / 2) * Math.PI) / 180;
const side =
Math.sin(half) * radius +
(width * Math.cos(half) + height * Math.sin(half)) / 2;
const fanWidth = 2 * (side + 8);
// how far an edge card's outer bottom corner dips below the resting baseline
const dip =
(1 - Math.cos(half)) * (radius - height / 2) +
Math.sin(half) * (width / 2);
const fanHeight = height + lift + dip + 8;
const spring = reduced
? ({ duration: 0 } as const)
: ({ type: "spring", stiffness: 300, damping: 26 } as const);
return (
<div
role="group"
aria-label={`Fan of ${n} cards`}
className={["relative", className].filter(Boolean).join(" ")}
style={{ width: fanWidth, height: fanHeight, ...style }}
onMouseLeave={() => setActive(null)}
>
{items.map((item, i) => {
const isActive = active === i;
// neighbours lean away from the drawn card, closest the most
const push =
active === null || isActive
? 0
: (Math.sign(i - active) * 7) / Math.abs(i - active);
return (
/* outer owns the fan slot and the pointer target — it never moves
on hover, so the lifted card can't slip out from under the cursor */
<motion.div
key={i}
role="button"
tabIndex={0}
aria-pressed={isActive}
aria-label={`Card ${i + 1} of ${n}`}
className="absolute left-1/2 cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-[rgba(var(--ink-rgb),0.35)]"
initial={reduced ? false : { rotate: 0, opacity: 0 }}
animate={{ rotate: baseAngle(i), opacity: 1 }}
transition={{ ...spring, delay: reduced ? 0 : i * 0.05 }}
onMouseEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
onClick={() => onSelect?.(i)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.(i);
}
}}
style={{
width,
height,
bottom: dip,
marginLeft: -width / 2,
transformOrigin: origin,
zIndex: isActive ? n + 1 : i,
}}
>
{/* inner owns the gesture — straightens in place and lifts */}
<motion.div
animate={{
rotate: isActive ? -baseAngle(i) * 0.65 : push,
x: isActive ? 0 : push * 2.4,
y: isActive ? -lift : 0,
}}
transition={spring}
className="h-full w-full overflow-hidden rounded-xl border border-[var(--rule)] bg-[var(--background)] shadow-[0_20px_44px_-22px_rgba(0,0,0,0.5)]"
>
{item}
</motion.div>
</motion.div>
);
})}
</div>
);
}
export default CardFan;
Usage notes:
Add a CardFan component from the mellow library — a hand of cards fanned in an arc — hover one and it straightens and rises out of the fan while its neighbours part in sympathy. Key props: `items`, `width`, `height`, `arc`, `lift`. Copy the file into your project; it is self-contained 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