空状态
New带线稿动画插图的空状态区块:信封收件箱清零、悬停弹开抽屉的档案柜、来回扫描的搜索放大镜;统一「插图 + 短文案 + 主操作」结构。
收件箱清零
inbox.tsx线稿信封开盖,最后一封信轻轻抬出,弹簧对勾徽章落定,随后缓慢浮动待机。
Inbox zero
You're all caught up. New replies will land here.
TSXcomponents/previews/blocks/empty-state-inbox.preview.tsx
"use client";
import { EmptyStateInbox } from "@/components/motion/empty-state/inbox";
export function EmptyStateInboxPreview() {
return (
<div className="w-full">
<EmptyStateInbox />
</div>
);
}
TSXcomponents/motion/empty-state/inbox.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/empty-state
import { motion, useReducedMotion } from "motion/react";
import { EASE_IN_OUT, EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import {
EmptyStateAction,
EmptyStateCopy,
EmptyStateStage,
type EmptyStateProps,
} from "./shared";
const INBOX_DEFAULTS = {
title: "Inbox zero",
message: "You're all caught up. New replies will land here.",
actionLabel: "Compose",
} as const;
// Sequence: the flap lifts first, the letter rises out shortly after (while the
// flap is still opening), then the badge springs in once the letter has landed.
const FLAP_DELAY = 0.05;
const LETTER_DELAY = 0.28;
const BADGE_DELAY = 0.58;
const IDLE_DELAY = LETTER_DELAY + 0.5;
export function EmptyStateInbox({
title = INBOX_DEFAULTS.title,
message = INBOX_DEFAULTS.message,
actionLabel = INBOX_DEFAULTS.actionLabel,
onAction,
className,
}: EmptyStateProps) {
const reduce = useReducedMotion();
return (
<EmptyStateStage className={className}>
<svg
viewBox="0 0 160 160"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-40 w-40 text-muted-foreground"
>
{/* Pocket: open-top envelope body. */}
<path d="M34 58 V118 L42 126 H118 L126 118 V58" />
{/* Letter: lifts out of the pocket, then floats gently forever. */}
<motion.g
initial={reduce ? { opacity: 1, y: 0 } : { opacity: 0, y: 28 }}
animate={{ opacity: 1, y: -18 }}
transition={
reduce
? { duration: 0 }
: { duration: 0.5, ease: EASE_OUT, delay: LETTER_DELAY }
}
>
<motion.g
animate={reduce ? undefined : { y: [0, -2, 0, 2, 0] }}
transition={
reduce
? undefined
: {
duration: 3.2,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: IDLE_DELAY,
}
}
>
<rect x="58" y="72" width="44" height="30" rx="3" />
<line x1="66" y1="82" x2="94" y2="82" />
<line x1="66" y1="90" x2="86" y2="90" />
</motion.g>
</motion.g>
{/* Flap: swings open like a hinge anchored at the pocket's top edge. */}
<motion.path
d="M34 58 L80 30 L126 58"
style={{ transformOrigin: "80px 58px" }}
initial={reduce ? { scaleY: 1 } : { scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={
reduce
? { duration: 0 }
: { duration: 0.4, ease: EASE_OUT, delay: FLAP_DELAY }
}
/>
{/* Badge: checkmark springs in once the inbox has settled. */}
<motion.g
className="text-primary"
style={{ transformOrigin: "128px 40px" }}
initial={
reduce ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.4 }
}
animate={{ opacity: 1, scale: 1 }}
transition={
reduce ? { duration: 0 } : { ...SPRING_SWAP, delay: BADGE_DELAY }
}
>
<circle cx="128" cy="40" r="16" />
<motion.path
d="M120 40l5 5l11-11"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: { duration: 0.3, ease: EASE_OUT, delay: BADGE_DELAY + 0.2 }
}
/>
</motion.g>
</svg>
<EmptyStateCopy title={title} message={message} />
<EmptyStateAction actionLabel={actionLabel} onAction={onAction} />
</EmptyStateStage>
);
}
安装
$ bunx --bun shadcn add @uilab/empty-state-inbox
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react 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))
}
TSXlib/hooks/use-hover-capable.ts
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
TSXcomponents/motion/empty-state/inbox.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/empty-state
import { motion, useReducedMotion } from "motion/react";
import { EASE_IN_OUT, EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import {
EmptyStateAction,
EmptyStateCopy,
EmptyStateStage,
type EmptyStateProps,
} from "./shared";
const INBOX_DEFAULTS = {
title: "Inbox zero",
message: "You're all caught up. New replies will land here.",
actionLabel: "Compose",
} as const;
// Sequence: the flap lifts first, the letter rises out shortly after (while the
// flap is still opening), then the badge springs in once the letter has landed.
const FLAP_DELAY = 0.05;
const LETTER_DELAY = 0.28;
const BADGE_DELAY = 0.58;
const IDLE_DELAY = LETTER_DELAY + 0.5;
export function EmptyStateInbox({
title = INBOX_DEFAULTS.title,
message = INBOX_DEFAULTS.message,
actionLabel = INBOX_DEFAULTS.actionLabel,
onAction,
className,
}: EmptyStateProps) {
const reduce = useReducedMotion();
return (
<EmptyStateStage className={className}>
<svg
viewBox="0 0 160 160"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-40 w-40 text-muted-foreground"
>
{/* Pocket: open-top envelope body. */}
<path d="M34 58 V118 L42 126 H118 L126 118 V58" />
{/* Letter: lifts out of the pocket, then floats gently forever. */}
<motion.g
initial={reduce ? { opacity: 1, y: 0 } : { opacity: 0, y: 28 }}
animate={{ opacity: 1, y: -18 }}
transition={
reduce
? { duration: 0 }
: { duration: 0.5, ease: EASE_OUT, delay: LETTER_DELAY }
}
>
<motion.g
animate={reduce ? undefined : { y: [0, -2, 0, 2, 0] }}
transition={
reduce
? undefined
: {
duration: 3.2,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: IDLE_DELAY,
}
}
>
<rect x="58" y="72" width="44" height="30" rx="3" />
<line x1="66" y1="82" x2="94" y2="82" />
<line x1="66" y1="90" x2="86" y2="90" />
</motion.g>
</motion.g>
{/* Flap: swings open like a hinge anchored at the pocket's top edge. */}
<motion.path
d="M34 58 L80 30 L126 58"
style={{ transformOrigin: "80px 58px" }}
initial={reduce ? { scaleY: 1 } : { scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={
reduce
? { duration: 0 }
: { duration: 0.4, ease: EASE_OUT, delay: FLAP_DELAY }
}
/>
{/* Badge: checkmark springs in once the inbox has settled. */}
<motion.g
className="text-primary"
style={{ transformOrigin: "128px 40px" }}
initial={
reduce ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.4 }
}
animate={{ opacity: 1, scale: 1 }}
transition={
reduce ? { duration: 0 } : { ...SPRING_SWAP, delay: BADGE_DELAY }
}
>
<circle cx="128" cy="40" r="16" />
<motion.path
d="M120 40l5 5l11-11"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: { duration: 0.3, ease: EASE_OUT, delay: BADGE_DELAY + 0.2 }
}
/>
</motion.g>
</svg>
<EmptyStateCopy title={title} message={message} />
<EmptyStateAction actionLabel={actionLabel} onAction={onAction} />
</EmptyStateStage>
);
}
TSXcomponents/motion/empty-state/shared.tsx
"use client";
import type { ReactNode } from "react";
import { Button } from "@/components/motion/button";
import { cn } from "@/lib/utils";
export interface EmptyStateProps {
title?: string;
message?: string;
actionLabel?: string;
onAction?: () => void;
className?: string;
}
/** Centers an illustration, copy, and action into a consistent column. */
export function EmptyStateStage({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div
className={cn(
"flex w-full flex-col items-center justify-center gap-6 px-6 py-12 text-center",
className,
)}
>
{children}
</div>
);
}
/** Title (semibold) + muted, width-capped message — shared copy block. */
export function EmptyStateCopy({
title,
message,
}: {
title: string;
message: string;
}) {
return (
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-xs text-sm text-muted-foreground">{message}</p>
</div>
);
}
type EmptyStateActionProps = Pick<EmptyStateProps, "actionLabel" | "onAction">;
/** Primary CTA. Renders nothing when no label is given. */
export function EmptyStateAction({
actionLabel,
onAction,
}: EmptyStateActionProps) {
if (!actionLabel) return null;
return (
<Button variant="primary" onClick={onAction}>
{actionLabel}
</Button>
);
}
TSXcomponents/motion/button/index.tsx
export { Button } from "./base";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./base";
export { StatefulButton } from "./stateful";
export type { StatefulButtonProps, ButtonState } from "./stateful";
export { MagneticButton } from "./magnetic";
export type { MagneticButtonProps } from "./magnetic";
TSXcomponents/motion/button/base.tsx
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
setRipples((prev) => [
...prev,
{
id: nextId.current++,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
TSXcomponents/motion/button/magnetic.tsx
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
TSXcomponents/motion/button/stateful.tsx
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { Check, Loader2, X } from "lucide-react";
import {
forwardRef,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
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 ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Width is set instantly from the measurer; the parent's single `layout`
// animation smooths the resize (text + icons together) so nothing competes.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
<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, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * 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={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
TSXcomponents/motion/magnetic.tsx
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API 参考
title?stringInbox zeromessage?stringYou're all caught up. New replies will land here.actionLabel?stringComposeonAction?(() => void)—className?string—档案柜抽屉
archive.tsx等距视角档案柜,悬停或聚焦时抽屉弹簧滑开,纸页从中探出。
Nothing archived yet
Records you file away will show up here.
TSXcomponents/previews/blocks/empty-state-archive.preview.tsx
"use client";
import { EmptyStateArchive } from "@/components/motion/empty-state/archive";
export function EmptyStateArchivePreview() {
return (
<div className="w-full">
<EmptyStateArchive />
</div>
);
}
TSXcomponents/motion/empty-state/archive.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/empty-state
import { motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import {
EmptyStateAction,
EmptyStateCopy,
EmptyStateStage,
type EmptyStateProps,
} from "./shared";
const ARCHIVE_DEFAULTS = {
title: "Nothing archived yet",
message: "Records you file away will show up here.",
actionLabel: "Add record",
} as const;
// The top drawer slides along the same oblique "depth" axis used to draw the
// cabinet's lid and side face, so it reads as pulling out toward the viewer.
const DRAWER_OPEN = { x: -9, y: 5 };
const DRAWER_SHUT = { x: 0, y: 0 };
export function EmptyStateArchive({
title = ARCHIVE_DEFAULTS.title,
message = ARCHIVE_DEFAULTS.message,
actionLabel = ARCHIVE_DEFAULTS.actionLabel,
onAction,
className,
}: EmptyStateProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [active, setActive] = useState(false);
const open = active && !reduce;
const handleEnter = () => {
if (!canHover) return;
setActive(true);
};
const handleLeave = () => {
if (!canHover) return;
setActive(false);
};
return (
<EmptyStateStage className={className}>
<button
type="button"
aria-label="Archive cabinet"
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onFocus={() => setActive(true)}
onBlur={() => setActive(false)}
className="inline-flex items-center justify-center rounded-3xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<svg
viewBox="0 0 160 160"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-40 w-40 text-muted-foreground"
>
{/* Lid: top face of the cabinet, oblique projection. */}
<path d="M40 50 L104 50 L124 38 L60 38 Z" />
{/* Side: right face, sharing the lid's depth axis. */}
<path d="M104 50 L104 126 L124 114 L124 38 Z" />
{/* Papers: peek up above the lid while the top drawer is pulled open. */}
<motion.g
animate={
open
? { opacity: 1, y: 0 }
: { opacity: 0, y: reduce ? 0 : 10 }
}
transition={{ duration: 0.22, ease: EASE_OUT }}
>
<rect
x="54"
y="14"
width="18"
height="24"
rx="2"
transform="rotate(-6 63 26)"
/>
<rect
x="76"
y="16"
width="18"
height="24"
rx="2"
transform="rotate(5 85 28)"
/>
</motion.g>
{/* Top drawer: slides along the depth axis on hover/focus. */}
<motion.g
animate={open ? DRAWER_OPEN : DRAWER_SHUT}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
>
<rect x="40" y="50" width="64" height="37" rx="2" />
<line x1="62" y1="69" x2="82" y2="69" />
</motion.g>
{/* Bottom drawer: static. */}
<rect x="40" y="89" width="64" height="37" rx="2" />
<line x1="62" y1="107" x2="82" y2="107" />
</svg>
</button>
<EmptyStateCopy title={title} message={message} />
<EmptyStateAction actionLabel={actionLabel} onAction={onAction} />
</EmptyStateStage>
);
}
安装
$ bunx --bun shadcn add @uilab/empty-state-archive
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react 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/hooks/use-hover-capable.ts
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
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/empty-state/archive.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/empty-state
import { motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import {
EmptyStateAction,
EmptyStateCopy,
EmptyStateStage,
type EmptyStateProps,
} from "./shared";
const ARCHIVE_DEFAULTS = {
title: "Nothing archived yet",
message: "Records you file away will show up here.",
actionLabel: "Add record",
} as const;
// The top drawer slides along the same oblique "depth" axis used to draw the
// cabinet's lid and side face, so it reads as pulling out toward the viewer.
const DRAWER_OPEN = { x: -9, y: 5 };
const DRAWER_SHUT = { x: 0, y: 0 };
export function EmptyStateArchive({
title = ARCHIVE_DEFAULTS.title,
message = ARCHIVE_DEFAULTS.message,
actionLabel = ARCHIVE_DEFAULTS.actionLabel,
onAction,
className,
}: EmptyStateProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [active, setActive] = useState(false);
const open = active && !reduce;
const handleEnter = () => {
if (!canHover) return;
setActive(true);
};
const handleLeave = () => {
if (!canHover) return;
setActive(false);
};
return (
<EmptyStateStage className={className}>
<button
type="button"
aria-label="Archive cabinet"
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onFocus={() => setActive(true)}
onBlur={() => setActive(false)}
className="inline-flex items-center justify-center rounded-3xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<svg
viewBox="0 0 160 160"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-40 w-40 text-muted-foreground"
>
{/* Lid: top face of the cabinet, oblique projection. */}
<path d="M40 50 L104 50 L124 38 L60 38 Z" />
{/* Side: right face, sharing the lid's depth axis. */}
<path d="M104 50 L104 126 L124 114 L124 38 Z" />
{/* Papers: peek up above the lid while the top drawer is pulled open. */}
<motion.g
animate={
open
? { opacity: 1, y: 0 }
: { opacity: 0, y: reduce ? 0 : 10 }
}
transition={{ duration: 0.22, ease: EASE_OUT }}
>
<rect
x="54"
y="14"
width="18"
height="24"
rx="2"
transform="rotate(-6 63 26)"
/>
<rect
x="76"
y="16"
width="18"
height="24"
rx="2"
transform="rotate(5 85 28)"
/>
</motion.g>
{/* Top drawer: slides along the depth axis on hover/focus. */}
<motion.g
animate={open ? DRAWER_OPEN : DRAWER_SHUT}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
>
<rect x="40" y="50" width="64" height="37" rx="2" />
<line x1="62" y1="69" x2="82" y2="69" />
</motion.g>
{/* Bottom drawer: static. */}
<rect x="40" y="89" width="64" height="37" rx="2" />
<line x1="62" y1="107" x2="82" y2="107" />
</svg>
</button>
<EmptyStateCopy title={title} message={message} />
<EmptyStateAction actionLabel={actionLabel} onAction={onAction} />
</EmptyStateStage>
);
}
TSXcomponents/motion/empty-state/shared.tsx
"use client";
import type { ReactNode } from "react";
import { Button } from "@/components/motion/button";
import { cn } from "@/lib/utils";
export interface EmptyStateProps {
title?: string;
message?: string;
actionLabel?: string;
onAction?: () => void;
className?: string;
}
/** Centers an illustration, copy, and action into a consistent column. */
export function EmptyStateStage({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div
className={cn(
"flex w-full flex-col items-center justify-center gap-6 px-6 py-12 text-center",
className,
)}
>
{children}
</div>
);
}
/** Title (semibold) + muted, width-capped message — shared copy block. */
export function EmptyStateCopy({
title,
message,
}: {
title: string;
message: string;
}) {
return (
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-xs text-sm text-muted-foreground">{message}</p>
</div>
);
}
type EmptyStateActionProps = Pick<EmptyStateProps, "actionLabel" | "onAction">;
/** Primary CTA. Renders nothing when no label is given. */
export function EmptyStateAction({
actionLabel,
onAction,
}: EmptyStateActionProps) {
if (!actionLabel) return null;
return (
<Button variant="primary" onClick={onAction}>
{actionLabel}
</Button>
);
}
TSXcomponents/motion/button/index.tsx
export { Button } from "./base";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./base";
export { StatefulButton } from "./stateful";
export type { StatefulButtonProps, ButtonState } from "./stateful";
export { MagneticButton } from "./magnetic";
export type { MagneticButtonProps } from "./magnetic";
TSXcomponents/motion/button/base.tsx
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
setRipples((prev) => [
...prev,
{
id: nextId.current++,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
TSXcomponents/motion/button/magnetic.tsx
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
TSXcomponents/motion/button/stateful.tsx
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { Check, Loader2, X } from "lucide-react";
import {
forwardRef,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
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 ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Width is set instantly from the measurer; the parent's single `layout`
// animation smooths the resize (text + icons together) so nothing competes.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
<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, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * 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={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
TSXcomponents/motion/magnetic.tsx
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API 参考
title?stringNothing archived yetmessage?stringRecords you file away will show up here.actionLabel?stringAdd recordonAction?(() => void)—className?string—搜索无果
search.tsx放大镜在几行虚线结果上来回扫动,行影闪烁始终落不定——查无匹配。
No matches
Try fewer filters or a different phrase.
TSXcomponents/previews/blocks/empty-state-search.preview.tsx
"use client";
import { EmptyStateSearch } from "@/components/motion/empty-state/search";
export function EmptyStateSearchPreview() {
return (
<div className="w-full">
<EmptyStateSearch />
</div>
);
}
TSXcomponents/motion/empty-state/search.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/empty-state
import { motion, useReducedMotion } from "motion/react";
import { EASE_IN_OUT, EASE_OUT } from "@/lib/ease";
import {
EmptyStateAction,
EmptyStateCopy,
EmptyStateStage,
type EmptyStateProps,
} from "./shared";
const SEARCH_DEFAULTS = {
title: "No matches",
message: "Try fewer filters or a different phrase.",
actionLabel: "Clear filters",
} as const;
// Result rows the glass sweeps over; each flickers on its own staggered delay.
const ROW_Y = [112, 126, 140] as const;
export function EmptyStateSearch({
title = SEARCH_DEFAULTS.title,
message = SEARCH_DEFAULTS.message,
actionLabel = SEARCH_DEFAULTS.actionLabel,
onAction,
className,
}: EmptyStateProps) {
const reduce = useReducedMotion();
return (
<EmptyStateStage className={className}>
<motion.svg
viewBox="0 0 160 160"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-40 w-40 text-muted-foreground"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={
reduce ? { duration: 0 } : { duration: 0.4, ease: EASE_OUT }
}
>
{/* Result rows: dashed lines that flicker in sequence, as if scanned and skipped. */}
{ROW_Y.map((y, i) => (
<motion.line
key={y}
x1={36}
y1={y}
x2={124}
y2={y}
strokeDasharray="4 6"
animate={reduce ? undefined : { opacity: [1, 0.3, 1] }}
transition={
reduce
? undefined
: {
duration: 1.8,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: 0.5 + i * 0.35,
}
}
/>
))}
{/* Glass: slow scan sweep, as if searching but never landing on a match. */}
<motion.g
style={{ transformOrigin: "68px 62px" }}
animate={
reduce
? undefined
: { x: [0, 8, 0, -8, 0], rotate: [0, 4, 0, -4, 0] }
}
transition={
reduce
? undefined
: {
duration: 5.4,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: 0.4,
}
}
>
<circle cx="68" cy="62" r="30" />
<line x1="90" y1="84" x2="114" y2="108" />
</motion.g>
</motion.svg>
<EmptyStateCopy title={title} message={message} />
<EmptyStateAction actionLabel={actionLabel} onAction={onAction} />
</EmptyStateStage>
);
}
安装
$ bunx --bun shadcn add @uilab/empty-state-search
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react 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))
}
TSXlib/hooks/use-hover-capable.ts
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
TSXcomponents/motion/empty-state/search.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/empty-state
import { motion, useReducedMotion } from "motion/react";
import { EASE_IN_OUT, EASE_OUT } from "@/lib/ease";
import {
EmptyStateAction,
EmptyStateCopy,
EmptyStateStage,
type EmptyStateProps,
} from "./shared";
const SEARCH_DEFAULTS = {
title: "No matches",
message: "Try fewer filters or a different phrase.",
actionLabel: "Clear filters",
} as const;
// Result rows the glass sweeps over; each flickers on its own staggered delay.
const ROW_Y = [112, 126, 140] as const;
export function EmptyStateSearch({
title = SEARCH_DEFAULTS.title,
message = SEARCH_DEFAULTS.message,
actionLabel = SEARCH_DEFAULTS.actionLabel,
onAction,
className,
}: EmptyStateProps) {
const reduce = useReducedMotion();
return (
<EmptyStateStage className={className}>
<motion.svg
viewBox="0 0 160 160"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-40 w-40 text-muted-foreground"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={
reduce ? { duration: 0 } : { duration: 0.4, ease: EASE_OUT }
}
>
{/* Result rows: dashed lines that flicker in sequence, as if scanned and skipped. */}
{ROW_Y.map((y, i) => (
<motion.line
key={y}
x1={36}
y1={y}
x2={124}
y2={y}
strokeDasharray="4 6"
animate={reduce ? undefined : { opacity: [1, 0.3, 1] }}
transition={
reduce
? undefined
: {
duration: 1.8,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: 0.5 + i * 0.35,
}
}
/>
))}
{/* Glass: slow scan sweep, as if searching but never landing on a match. */}
<motion.g
style={{ transformOrigin: "68px 62px" }}
animate={
reduce
? undefined
: { x: [0, 8, 0, -8, 0], rotate: [0, 4, 0, -4, 0] }
}
transition={
reduce
? undefined
: {
duration: 5.4,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: 0.4,
}
}
>
<circle cx="68" cy="62" r="30" />
<line x1="90" y1="84" x2="114" y2="108" />
</motion.g>
</motion.svg>
<EmptyStateCopy title={title} message={message} />
<EmptyStateAction actionLabel={actionLabel} onAction={onAction} />
</EmptyStateStage>
);
}
TSXcomponents/motion/empty-state/shared.tsx
"use client";
import type { ReactNode } from "react";
import { Button } from "@/components/motion/button";
import { cn } from "@/lib/utils";
export interface EmptyStateProps {
title?: string;
message?: string;
actionLabel?: string;
onAction?: () => void;
className?: string;
}
/** Centers an illustration, copy, and action into a consistent column. */
export function EmptyStateStage({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div
className={cn(
"flex w-full flex-col items-center justify-center gap-6 px-6 py-12 text-center",
className,
)}
>
{children}
</div>
);
}
/** Title (semibold) + muted, width-capped message — shared copy block. */
export function EmptyStateCopy({
title,
message,
}: {
title: string;
message: string;
}) {
return (
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-xs text-sm text-muted-foreground">{message}</p>
</div>
);
}
type EmptyStateActionProps = Pick<EmptyStateProps, "actionLabel" | "onAction">;
/** Primary CTA. Renders nothing when no label is given. */
export function EmptyStateAction({
actionLabel,
onAction,
}: EmptyStateActionProps) {
if (!actionLabel) return null;
return (
<Button variant="primary" onClick={onAction}>
{actionLabel}
</Button>
);
}
TSXcomponents/motion/button/index.tsx
export { Button } from "./base";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./base";
export { StatefulButton } from "./stateful";
export type { StatefulButtonProps, ButtonState } from "./stateful";
export { MagneticButton } from "./magnetic";
export type { MagneticButtonProps } from "./magnetic";
TSXcomponents/motion/button/base.tsx
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
setRipples((prev) => [
...prev,
{
id: nextId.current++,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
TSXcomponents/motion/button/magnetic.tsx
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
TSXcomponents/motion/button/stateful.tsx
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { Check, Loader2, X } from "lucide-react";
import {
forwardRef,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
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 ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Width is set instantly from the measurer; the parent's single `layout`
// animation smooths the resize (text + icons together) so nothing competes.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
<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, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * 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={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
TSXcomponents/motion/magnetic.tsx
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API 参考
title?stringNo matchesmessage?stringTry fewer filters or a different phrase.actionLabel?stringClear filtersonAction?(() => void)—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.