Feedback
加载与空状态:Spinner、Skeleton 扫光、顶部进度条 TopLoadingBar、EmptyState。
'use client';
import { useEffect, useState } from 'react';
import { MagnifyingGlass, Plus } from '@phosphor-icons/react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState, Skeleton, Spinner, TopLoadingBar, useTopLoadingBar } from '@/components/ui/feedback';
import { Segmented } from '@/components/ui/segmented';
const notes = [
{ t: '周会记录 10/18', d: '确认下季度路线图与招聘计划', a: '藤' },
{ t: '读书笔记 ·《设计心理学》', d: '可供性、意符与反馈', a: '葵' },
{ t: '旅行清单', d: '京都 · 11 月 · 三晚', a: '社' },
];
export default function FeedbackDemo() {
const [view, setView] = useState<'list' | 'empty'>('list');
const [loading, setLoading] = useState(true);
const bar = useTopLoadingBar();
const reload = () => {
setLoading(true);
bar.start();
setTimeout(() => {
setLoading(false);
bar.done();
}, 1600);
};
useEffect(() => {
const id = setTimeout(() => setLoading(false), 1400);
return () => clearTimeout(id);
}, []);
return (
<div className="flex w-full max-w-[400px] flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<Segmented
ariaLabel="视图"
value={view}
onChange={setView}
options={[
{ value: 'list', label: '列表' },
{ value: 'empty', label: '空状态' },
]}
/>
<div className="flex items-center gap-3 text-text-secondary">
<Spinner size="xs" label="加载中" />
<Spinner size="sm" label="加载中" />
<Spinner size="md" label="加载中" />
<Spinner size="lg" label="加载中" className="text-accent-brand" />
</div>
</div>
<Card className="relative overflow-hidden">
<TopLoadingBar {...bar.props} position="absolute" label="加载中" />
{view === 'empty' ? (
<EmptyState
icon={<MagnifyingGlass />}
title="没有找到「京都美食」"
description="换个关键词试试,或者新建一篇笔记。"
action={
<Button size="sm">
<Plus /> 新建
</Button>
}
/>
) : (
<ul className="divide-y divide-hairline">
{notes.map((n) => (
<li key={n.t} className="flex items-center gap-3 px-4 py-3">
{loading ? (
<>
<Skeleton circle className="size-8" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3 w-5/6" />
</div>
</>
) : (
<>
<span className="inline-flex size-8 items-center justify-center rounded-full bg-accent-soft text-[13px] font-medium text-accent-text">{n.a}</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-text-primary">{n.t}</div>
<div className="truncate text-[13px] text-text-secondary">{n.d}</div>
</div>
</>
)}
</li>
))}
</ul>
)}
</Card>
<Button variant="ghost" size="sm" className="self-start" onClick={reload} disabled={bar.loading}>
{bar.loading ? <Spinner size="sm" label="加载中" /> : null}
重新加载
</Button>
</div>
);
}
安装
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { useCallback, useEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
import { SPRING_GENTLE } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
/* ==========================================================================
Spinner
========================================================================== */
const spinnerSize = { xs: 'size-3', sm: 'size-3.5', md: 'size-4', lg: 'size-5', xl: 'size-6' };
export interface SpinnerProps {
size?: keyof typeof spinnerSize;
/** 读屏文本,默认 "Loading" */
label?: string;
className?: string;
}
/** 继承 currentColor 的圆环转圈 */
export function Spinner({ size = 'md', label = 'Loading', className }: SpinnerProps) {
return (
<svg
role="status"
aria-label={label}
viewBox="0 0 16 16"
fill="none"
className={twMerge('shrink-0 animate-spin text-current', spinnerSize[size], className)}
>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.22" strokeWidth="2" />
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
/* ==========================================================================
Skeleton:骨架 + 扫光;减弱动效时为静态色块
========================================================================== */
export interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
/** 圆形(头像) */
circle?: boolean;
}
export function Skeleton({ className, circle, ...p }: SkeletonProps) {
const reduce = useReducedMotionPref();
return (
<div
aria-hidden
className={twMerge('relative h-4 overflow-hidden bg-ghost-selected', circle ? 'rounded-full' : 'rounded-md', className)}
{...p}
>
{!reduce && (
<motion.div
className="absolute inset-y-0 left-0 w-full bg-[linear-gradient(90deg,transparent,var(--cds-alpha-1)_40%,var(--cds-alpha-2)_50%,var(--cds-alpha-1)_60%,transparent)]"
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ duration: 1.6, ease: [0.4, 0, 0.6, 1], repeat: Infinity, repeatDelay: 0.2 }}
/>
)}
</div>
);
}
/* ==========================================================================
TopLoadingBar:顶部细进度条(主题色),涓流增长;loading=false 时冲到 100% 再淡出
========================================================================== */
export interface TopLoadingBarProps {
loading: boolean;
/** fixed(默认,贴视口顶)或 absolute(贴最近的 relative 容器顶) */
position?: 'fixed' | 'absolute';
/** 高度 px,默认 2 */
height?: number;
className?: string;
label?: string;
}
export function TopLoadingBar({ loading, position = 'fixed', height = 2, className, label = 'Loading' }: TopLoadingBarProps) {
const [progress, setProgress] = useState(0);
const [visible, setVisible] = useState(false);
const reduce = useReducedMotionPref();
const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
clearTimeout(hideTimer.current);
if (loading) {
setVisible(true);
setProgress(0.08);
const id = setInterval(() => {
setProgress((p) => {
// 越接近 90% 越慢
const step = p < 0.3 ? 0.06 : p < 0.6 ? 0.03 : p < 0.85 ? 0.012 : 0.003;
return Math.min(0.94, p + step * (0.6 + Math.random() * 0.8));
});
}, 260);
return () => clearInterval(id);
}
if (!visible) return;
setProgress(1);
hideTimer.current = setTimeout(() => {
setVisible(false);
setProgress(0);
}, 420);
return () => clearTimeout(hideTimer.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading]);
return (
<AnimatePresence>
{visible && (
<motion.div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)}
className={twMerge('pointer-events-none inset-x-0 top-0 z-[80]', position, className)}
style={{ height }}
initial={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: reduce ? 0 : 0.25, delay: 0.12 } }}
>
<motion.div
className="h-full origin-left rounded-r-full bg-accent-brand shadow-[0_0_8px_var(--cds-accent)]"
initial={{ scaleX: 0 }}
animate={{ scaleX: progress }}
transition={reduce ? { duration: 0 } : SPRING_GENTLE}
/>
</motion.div>
)}
</AnimatePresence>
);
}
/** 命令式控制:const bar = useTopLoadingBar(); bar.start(); bar.done(); <TopLoadingBar {...bar.props} /> */
export function useTopLoadingBar() {
const [loading, setLoading] = useState(false);
const start = useCallback(() => setLoading(true), []);
const done = useCallback(() => setLoading(false), []);
/** 包裹一个 Promise:开始 → 结束 */
const track = useCallback(
async <T,>(p: Promise<T>) => {
start();
try {
return await p;
} finally {
done();
}
},
[start, done],
);
return { loading, start, done, track, props: { loading } };
}
/* ==========================================================================
EmptyState:柔色圆底图标 + 衬线标题 + 说明 + 操作
========================================================================== */
export interface EmptyStateProps {
icon?: ReactNode;
title: ReactNode;
description?: ReactNode;
/** 操作区:按钮等 */
action?: ReactNode;
className?: string;
}
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<div className={twMerge('flex flex-col items-center px-6 py-10 text-center', className)}>
{icon && (
<span aria-hidden className="mb-4 inline-flex size-12 items-center justify-center rounded-full bg-accent-soft text-accent-text [&_svg]:size-6">
{icon}
</span>
)}
<h3 className="font-title text-[17px] leading-snug text-text-primary">{title}</h3>
{description && <p className="mt-1.5 max-w-[320px] text-sm leading-relaxed text-text-secondary">{description}</p>}
{action && <div className="mt-5 flex flex-wrap items-center justify-center gap-2">{action}</div>}
</div>
);
}
用法
import { Spinner, Skeleton, TopLoadingBar, useTopLoadingBar, EmptyState } from "@/components/ui/feedback";
const bar = useTopLoadingBar(); // bar.start() / bar.done() / bar.track(promise)
<TopLoadingBar {...bar.props} />
<Skeleton className="h-4 w-2/3" />
<EmptyState icon={<MagnifyingGlass />} title="没有结果" action={<Button>新建</Button>} />| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
Spinner size | xs sm md lg xl | md | 12–24px,继承文字色 |
Skeleton circle | boolean | false | 圆形;减弱动效时无扫光 |
TopLoadingBar loading | boolean | — | 开始涓流 / 完成 |
position | fixed absolute | fixed | 贴视口或容器顶部 |
EmptyState icon / title / description / action | ReactNode | — | 空状态内容 |