文字动效
一组文字动效原语,涵盖逐字揭示、加载态微光扫过与字母级瀑布切换。
文字揭示
text-reveal.tsx按词或按字揭示文字,配合弹簧上滑与模糊过渡。
Motion that feelsconsidered.
Word by word, with a soft blur.TSXcomponents/previews/motion/text-reveal.preview.tsx
"use client";
import { useState } from "react";
import { TextReveal } from "@/components/motion/text-reveal";
export function TextRevealPreview() {
const [key, setKey] = useState(0);
return (
<div className="flex w-full flex-col items-center gap-8 text-center">
<div key={key} className="flex flex-col gap-2">
<TextReveal
as="h2"
text={["Motion that feels", "considered."]}
className="text-balance text-4xl font-semibold leading-[0.95] tracking-[-0.04em] text-foreground sm:text-5xl"
/>
<TextReveal
text="Word by word, with a soft blur."
delay={0.9}
stagger={0.05}
blur={6}
yOffset="20%"
className="text-sm text-muted-foreground"
/>
</div>
<button
type="button"
onClick={() => setKey((k) => k + 1)}
className="inline-flex h-9 items-center rounded-full border border-border bg-card px-4 text-xs font-medium text-foreground press hover:border-(--color-border-strong)"
>
Replay
</button>
</div>
);
}
TSXcomponents/motion/text-reveal.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/text-animation
import { motion, type Transition, useInView, useReducedMotion } from "motion/react";
import { useRef, type ElementType, type ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type SplitMode = "word" | "char";
export interface TextRevealProps {
text: string | string[];
as?: ElementType;
className?: string;
split?: SplitMode;
stagger?: number;
delay?: number;
blur?: number;
yOffset?: string | number;
spring?: { stiffness?: number; damping?: number; mass?: number };
once?: boolean;
whileInView?: boolean;
children?: ReactNode;
}
const DEFAULT_SPRING = { stiffness: 140, damping: 26, mass: 1.2 };
export function TextReveal({
text,
as: Comp = "span",
className,
split = "word",
stagger = 0.09,
delay = 0,
blur = 12,
yOffset = "40%",
spring,
once = true,
whileInView = false,
children,
}: TextRevealProps) {
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { once, amount: 0.4 });
const reduce = useReducedMotion();
const shouldAnimate = whileInView ? inView : true;
const lines = Array.isArray(text) ? text : [text];
const s = { ...DEFAULT_SPRING, ...spring };
let unitIndex = 0;
const lineCounts = new Map<string, number>();
return (
<Comp ref={ref} className={cn("block", className)}>
{lines.map((line) => {
const units = split === "word" ? line.split(" ") : Array.from(line);
const lineCount = lineCounts.get(line) ?? 0;
lineCounts.set(line, lineCount + 1);
const lineKey = `${line}-${lineCount}`;
const unitCounts = new Map<string, number>();
return (
<span key={lineKey} className="block">
{units.map((unit, i) => {
const d = delay + unitIndex * stagger;
unitIndex += 1;
const unitCount = unitCounts.get(unit) ?? 0;
unitCounts.set(unit, unitCount + 1);
const unitKey = `${unit}-${unitCount}`;
const initial = reduce
? { opacity: 0 }
: { y: yOffset, opacity: 0, filter: `blur(${blur}px)` };
const animate = shouldAnimate
? reduce
? { opacity: 1 }
: { y: 0, opacity: 1, filter: "blur(0px)" }
: initial;
const transition: Transition = reduce
? { opacity: { duration: 0.25, ease: EASE_OUT, delay: d * 0.3 } }
: {
y: { type: "spring" as const, ...s, delay: d },
opacity: { duration: 0.7, ease: EASE_OUT, delay: d },
filter: { duration: 0.9, ease: EASE_OUT, delay: d },
};
return (
<motion.span
key={unitKey}
initial={initial}
animate={animate}
transition={transition}
className="inline-block will-change-transform"
>
{unit}
{split === "word" && i < units.length - 1 ? (
<span className="inline-block"> </span>
) : null}
</motion.span>
);
})}
</span>
);
})}
{children}
</Comp>
);
}
安装
$ bunx --bun shadcn add @uilab/text-reveal
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/text-reveal.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/text-animation
import { motion, type Transition, useInView, useReducedMotion } from "motion/react";
import { useRef, type ElementType, type ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type SplitMode = "word" | "char";
export interface TextRevealProps {
text: string | string[];
as?: ElementType;
className?: string;
split?: SplitMode;
stagger?: number;
delay?: number;
blur?: number;
yOffset?: string | number;
spring?: { stiffness?: number; damping?: number; mass?: number };
once?: boolean;
whileInView?: boolean;
children?: ReactNode;
}
const DEFAULT_SPRING = { stiffness: 140, damping: 26, mass: 1.2 };
export function TextReveal({
text,
as: Comp = "span",
className,
split = "word",
stagger = 0.09,
delay = 0,
blur = 12,
yOffset = "40%",
spring,
once = true,
whileInView = false,
children,
}: TextRevealProps) {
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { once, amount: 0.4 });
const reduce = useReducedMotion();
const shouldAnimate = whileInView ? inView : true;
const lines = Array.isArray(text) ? text : [text];
const s = { ...DEFAULT_SPRING, ...spring };
let unitIndex = 0;
const lineCounts = new Map<string, number>();
return (
<Comp ref={ref} className={cn("block", className)}>
{lines.map((line) => {
const units = split === "word" ? line.split(" ") : Array.from(line);
const lineCount = lineCounts.get(line) ?? 0;
lineCounts.set(line, lineCount + 1);
const lineKey = `${line}-${lineCount}`;
const unitCounts = new Map<string, number>();
return (
<span key={lineKey} className="block">
{units.map((unit, i) => {
const d = delay + unitIndex * stagger;
unitIndex += 1;
const unitCount = unitCounts.get(unit) ?? 0;
unitCounts.set(unit, unitCount + 1);
const unitKey = `${unit}-${unitCount}`;
const initial = reduce
? { opacity: 0 }
: { y: yOffset, opacity: 0, filter: `blur(${blur}px)` };
const animate = shouldAnimate
? reduce
? { opacity: 1 }
: { y: 0, opacity: 1, filter: "blur(0px)" }
: initial;
const transition: Transition = reduce
? { opacity: { duration: 0.25, ease: EASE_OUT, delay: d * 0.3 } }
: {
y: { type: "spring" as const, ...s, delay: d },
opacity: { duration: 0.7, ease: EASE_OUT, delay: d },
filter: { duration: 0.9, ease: EASE_OUT, delay: d },
};
return (
<motion.span
key={unitKey}
initial={initial}
animate={animate}
transition={transition}
className="inline-block will-change-transform"
>
{unit}
{split === "word" && i < units.length - 1 ? (
<span className="inline-block"> </span>
) : null}
</motion.span>
);
})}
</span>
);
})}
{children}
</Comp>
);
}
API 参考
textstring | string[]—as?ElementTypespanclassName?string—split?"word" | "char"wordstagger?number0.09delay?number0blur?number12yOffset?string | number40%spring?{ stiffness?: number; damping?: number; mass?: number | undefined; } | undefined—once?booleantruewhileInView?booleanfalse文字微光
text-shimmer.tsx渐变光效在文字上扫过,用于加载态或强调效果。
Loading projects…Faster shimmer
TSXcomponents/previews/motion/text-shimmer.preview.tsx
"use client";
import { TextShimmer } from "@/components/motion/text-shimmer";
export function TextShimmerPreview() {
return (
<div className="flex flex-col gap-4">
<TextShimmer className="text-3xl font-semibold">Loading projects…</TextShimmer>
<TextShimmer duration={1.5} className="text-sm">Faster shimmer</TextShimmer>
</div>
);
}
TSXcomponents/motion/text-shimmer.tsx
// ui-lab-ten.vercel.app/components/motion/text-animation
import { cn } from "@/lib/utils";
import type { ElementType, ReactNode } from "react";
export interface TextShimmerProps {
children: ReactNode;
as?: ElementType;
duration?: number;
className?: string;
}
export function TextShimmer({ children, as: Comp = "span", duration = 2.5, className }: TextShimmerProps) {
return (
<>
<style>
{`@keyframes uilab-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}`}
</style>
<Comp
style={{ animation: `uilab-text-shimmer ${duration}s linear infinite` }}
className={cn(
"inline-block bg-[length:200%_100%] bg-clip-text text-transparent",
"bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]",
className,
)}
>
{children}
</Comp>
</>
);
}
安装
$ bunx --bun shadcn add @uilab/text-shimmer
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx tailwind-mergeAdd util file
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/text-shimmer.tsx
// ui-lab-ten.vercel.app/components/motion/text-animation
import { cn } from "@/lib/utils";
import type { ElementType, ReactNode } from "react";
export interface TextShimmerProps {
children: ReactNode;
as?: ElementType;
duration?: number;
className?: string;
}
export function TextShimmer({ children, as: Comp = "span", duration = 2.5, className }: TextShimmerProps) {
return (
<>
<style>
{`@keyframes uilab-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}`}
</style>
<Comp
style={{ animation: `uilab-text-shimmer ${duration}s linear infinite` }}
className={cn(
"inline-block bg-[length:200%_100%] bg-clip-text text-transparent",
"bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]",
className,
)}
>
{children}
</Comp>
</>
);
}
API 参考
as?ElementTypespanduration?number2.5className?string—文字瀑布流
text-cascade.tsx独立文字的逐字符老虎机式滚动切换——旧字母依次掉落,新字母从左到右落位。
Install skills
TSXcomponents/previews/motion/text-cascade.preview.tsx
"use client";
import { useEffect, useState } from "react";
import { TextCascade } from "@/components/motion/text-cascade";
const PHRASES = ["Install skills", "Open settings", "Ship updates"];
export function TextCascadePreview() {
const [phrase, setPhrase] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setPhrase((p) => (p + 1) % PHRASES.length);
}, 2400);
return () => window.clearInterval(id);
}, []);
return (
<div className="flex w-full justify-center">
<p className="text-lg font-medium text-foreground">
<TextCascade text={PHRASES[phrase] ?? PHRASES[0]} />
</p>
</div>
);
}
TSXcomponents/motion/text-cascade.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/text-animation
import { ActionSwapText } from "./action-swap";
export interface TextCascadeProps {
/** Current text. Changing it cascades the letters to the new value. */
text: string;
className?: string;
}
/**
* Letter-by-letter slot roll for standalone text — the old letters drop away
* as the new ones land, left to right. Same motion as the action-swap
* cascade variant, with a text-first API.
*/
export function TextCascade({ text, className }: TextCascadeProps) {
return (
<ActionSwapText value={text} animation="cascade" className={className}>
{text}
</ActionSwapText>
);
}
安装
$ bunx --bun shadcn add @uilab/text-cascade
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/text-cascade.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/text-animation
import { ActionSwapText } from "./action-swap";
export interface TextCascadeProps {
/** Current text. Changing it cascades the letters to the new value. */
text: string;
className?: string;
}
/**
* Letter-by-letter slot roll for standalone text — the old letters drop away
* as the new ones land, left to right. Same motion as the action-swap
* cascade variant, with a text-first API.
*/
export function TextCascade({ text, className }: TextCascadeProps) {
return (
<ActionSwapText value={text} animation="cascade" className={className}>
{text}
</ActionSwapText>
);
}
TSXcomponents/motion/action-swap.tsx
"use client";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ActionSwapItem = {
id: string;
label: ReactNode;
icon?: ReactNode;
ariaLabel?: string;
};
export type ActionSwapButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type ActionSwapButtonSize = "sm" | "md" | "lg" | "icon";
export type ActionSwapAnimation = "blur" | "roll" | "cascade";
/** Animations with a single-element variant set (cascade animates per letter). */
type CoreAnimation = "blur" | "roll";
export interface ActionSwapButtonProps extends Omit<
HTMLMotionProps<"button">,
"children" | "onChange"
> {
items: ActionSwapItem[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string, item: ActionSwapItem) => void;
variant?: ActionSwapButtonVariant;
size?: ActionSwapButtonSize;
animation?: ActionSwapAnimation;
iconOnly?: boolean;
cycle?: boolean;
}
export interface ActionSwapTextProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
export interface ActionSwapIconProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
const BLUR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const;
const ROLL_TRANSITION = { duration: 0.24, ease: EASE_OUT } as const;
const SWAP_BLUR = "blur(8px)";
const ROLL_BLUR = "blur(6px)";
// Cascade rolls the label one letter at a time, left to right. The leaving
// and landing strings overlap as independent layers (no shared cells), so
// proportional glyph widths never jitter. Exits cascade at half the enter
// stagger so the tail of the old label lingers briefly.
const CASCADE_STAGGER = 0.025;
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const TEXT_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.94,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: "115%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: "-115%",
filter: ROLL_BLUR,
transition: { duration: 0.18, ease: "easeInOut" },
},
},
};
const ICON_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.25,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: 16, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: -16,
filter: ROLL_BLUR,
transition: { duration: 0.18, ease: "easeInOut" },
},
},
};
const VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
outline: "border border-border bg-transparent text-foreground hover:bg-primary/5",
ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground",
};
const SIZE_CLASS: Record<ActionSwapButtonSize, string> = {
sm: "h-8 gap-1.5 rounded-full px-3 text-xs",
md: "h-10 gap-2 rounded-full px-4 text-sm",
lg: "h-12 gap-2.5 rounded-full px-5 text-base",
icon: "h-10 w-10 rounded-full",
};
export function ActionSwapText({
value,
children,
animation = "blur",
className,
}: ActionSwapTextProps) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));
});
// Cascade needs a plain string to split into letters; non-string content
// and reduced motion fall back to the closest single-element animation.
const label = typeof children === "string" ? children : null;
const cascade = animation === "cascade" && label !== null && !reduce;
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span
className={cn("relative inline-block overflow-hidden whitespace-nowrap align-bottom", className)}
style={{
width,
transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,
}}
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
{/* Letters are decorative fragments; readers get the whole label. */}
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, i) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.
key={i}
custom={i * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`${animation}-${value}`}
variants={TEXT_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</span>
);
}
export function ActionSwapIcon({
value,
children,
animation = "blur",
className,
}: ActionSwapIconProps) {
const reduce = useReducedMotion();
// Icons are single elements — cascade maps to its closest motion, roll.
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span className={cn("relative inline-grid shrink-0 place-items-center overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${animation}-${value}`}
aria-hidden
variants={ICON_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
</span>
);
}
export function ActionSwapButton({
items,
value,
defaultValue,
onValueChange,
variant = "secondary",
size = "md",
animation = "blur",
iconOnly = size === "icon",
cycle = true,
className,
disabled,
onClick,
...rest
}: ActionSwapButtonProps) {
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);
const currentValue = value ?? internalValue;
const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));
const activeItem = items[activeIndex] ?? items[0];
const hasIcon = items.some((item) => item.icon);
const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;
if (!activeItem) return null;
const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === "string" ? activeItem.label : undefined);
return (
<motion.button
type="button"
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center overflow-hidden font-medium transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
aria-label={accessibleLabel}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || disabled || !cycle || !nextItem) return;
if (value === undefined) setInternalValue(nextItem.id);
onValueChange?.(nextItem.id, nextItem);
}}
{...rest}
>
{hasIcon ? (
<ActionSwapIcon value={activeItem.id} animation={animation} className="h-4 w-4">
{activeItem.icon ?? null}
</ActionSwapIcon>
) : null}
{!iconOnly ? (
<ActionSwapText value={activeItem.id} animation={animation}>
{activeItem.label}
</ActionSwapText>
) : null}
</motion.button>
);
}
API 参考
textstringCurrent text. Changing it cascades the letters to the new value.
—className?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.