弹跳卡组
New一叠卡片进入视口时错峰弹跳着扇形展开。间距、旋转与错峰节奏可调;「减少动效」偏好下保留扇形布局,仅做透明度渐入。
TSXcomponents/previews/motion/bounce-cards.preview.tsx
"use client";
import { BounceCards } from "@/components/motion/bounce-cards";
const GRADIENTS = [
"from-violet-500 to-indigo-600",
"from-emerald-400 to-teal-600",
"from-pink-400 to-fuchsia-600",
"from-amber-400 to-orange-600",
"from-sky-400 to-blue-600",
];
export function BounceCardsPreview() {
return (
<div className="flex min-h-96 w-full items-center justify-center">
<BounceCards>
{GRADIENTS.map((gradient) => (
<div key={gradient} className={`h-full w-full bg-gradient-to-br ${gradient}`} />
))}
</BounceCards>
</div>
);
}
TSXcomponents/motion/bounce-cards.tsx
// ui-lab-ten.vercel.app/components/motion/bounce-cards
// Ported from motion-anything (nexu-io, Apache-2.0); upstream: reactbits.dev "Bounce Cards", redistributed with permission.
"use client";
import { motion, useInView, useReducedMotion, type Transition } from "motion/react";
import { useRef, type ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface BounceCardsProps {
/** Card contents, rendered as an absolutely stacked deck that fans open. */
children: ReactNode[];
/** Stagger between each card's entrance, in ms. */
stagger?: number;
/** Horizontal offset between adjacent cards at rest, in px. */
spacing?: number;
/** Rotation step between adjacent cards at rest, in degrees. */
rotation?: number;
className?: string;
}
const CARD_WIDTH = 132;
const CARD_HEIGHT = 176;
// None of the shared spring tokens in lib/ease.ts overshoot enough to read as
// a "bounce" — upstream animates with cubic-bezier(.34,1.56,.64,1), a springy
// ease with ~56% overshoot. Low damping here reproduces that snap as the deck
// fans open, instead of copying the CSS keyframe directly.
const SPRING_FAN: Transition = {
type: "spring",
stiffness: 280,
damping: 16,
mass: 0.9,
};
export function BounceCards({
children,
stagger = 90,
spacing = 46,
rotation = 9,
className,
}: BounceCardsProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const inView = useInView(ref, { once: true, amount: 0.3 });
const count = children.length;
const center = (count - 1) / 2;
const maxOffset = center * spacing;
const containerWidth = CARD_WIDTH + maxOffset * 2 + 32;
return (
<div
ref={ref}
style={{ width: containerWidth, height: CARD_HEIGHT + 32 }}
className={cn("relative", className)}
>
{children.map((card, index) => {
const offset = index - center;
const x = offset * spacing;
const rotate = offset * rotation;
const delay = (index * stagger) / 1000;
const rest = { opacity: 1, x, y: 0, rotate, scale: 1 };
// Reduced motion: pin transform to its resting value so only opacity
// animates — the fanned layout still appears, just without the leap.
const hidden = reduce
? { opacity: 0, x, y: 0, rotate, scale: 1 }
: { opacity: 0, x: 0, y: 20, rotate: 0, scale: 0.9 };
return (
<motion.div
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length card deck, position is the slot identity.
key={index}
initial={hidden}
animate={inView ? rest : hidden}
transition={{
default: { ...SPRING_FAN, delay },
opacity: { duration: 0.5, ease: EASE_OUT, delay },
}}
style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}
className="absolute inset-0 m-auto overflow-hidden rounded-2xl shadow-2xl shadow-black/40"
>
{card}
</motion.div>
);
})}
</div>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/bounce-cards
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/bounce-cards.tsx
// ui-lab-ten.vercel.app/components/motion/bounce-cards
// Ported from motion-anything (nexu-io, Apache-2.0); upstream: reactbits.dev "Bounce Cards", redistributed with permission.
"use client";
import { motion, useInView, useReducedMotion, type Transition } from "motion/react";
import { useRef, type ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface BounceCardsProps {
/** Card contents, rendered as an absolutely stacked deck that fans open. */
children: ReactNode[];
/** Stagger between each card's entrance, in ms. */
stagger?: number;
/** Horizontal offset between adjacent cards at rest, in px. */
spacing?: number;
/** Rotation step between adjacent cards at rest, in degrees. */
rotation?: number;
className?: string;
}
const CARD_WIDTH = 132;
const CARD_HEIGHT = 176;
// None of the shared spring tokens in lib/ease.ts overshoot enough to read as
// a "bounce" — upstream animates with cubic-bezier(.34,1.56,.64,1), a springy
// ease with ~56% overshoot. Low damping here reproduces that snap as the deck
// fans open, instead of copying the CSS keyframe directly.
const SPRING_FAN: Transition = {
type: "spring",
stiffness: 280,
damping: 16,
mass: 0.9,
};
export function BounceCards({
children,
stagger = 90,
spacing = 46,
rotation = 9,
className,
}: BounceCardsProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const inView = useInView(ref, { once: true, amount: 0.3 });
const count = children.length;
const center = (count - 1) / 2;
const maxOffset = center * spacing;
const containerWidth = CARD_WIDTH + maxOffset * 2 + 32;
return (
<div
ref={ref}
style={{ width: containerWidth, height: CARD_HEIGHT + 32 }}
className={cn("relative", className)}
>
{children.map((card, index) => {
const offset = index - center;
const x = offset * spacing;
const rotate = offset * rotation;
const delay = (index * stagger) / 1000;
const rest = { opacity: 1, x, y: 0, rotate, scale: 1 };
// Reduced motion: pin transform to its resting value so only opacity
// animates — the fanned layout still appears, just without the leap.
const hidden = reduce
? { opacity: 0, x, y: 0, rotate, scale: 1 }
: { opacity: 0, x: 0, y: 20, rotate: 0, scale: 0.9 };
return (
<motion.div
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length card deck, position is the slot identity.
key={index}
initial={hidden}
animate={inView ? rest : hidden}
transition={{
default: { ...SPRING_FAN, delay },
opacity: { duration: 0.5, ease: EASE_OUT, delay },
}}
style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}
className="absolute inset-0 m-auto overflow-hidden rounded-2xl shadow-2xl shadow-black/40"
>
{card}
</motion.div>
);
})}
</div>
);
}
API 参考
childrenReactNode[]Card contents, rendered as an absolutely stacked deck that fans open.
—stagger?numberStagger between each card's entrance, in ms.
90spacing?numberHorizontal offset between adjacent cards at rest, in px.
46rotation?numberRotation step between adjacent cards at rest, in degrees.
9className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.