MioUI

Feedback

Loading and empty states: Spinner, shimmering Skeleton, TopLoadingBar and EmptyState.

Installation

components/ui/feedback.tsx
'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>
  );
}

Usage

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="No results" action={<Button>New</Button>} />
PropTypeDefaultNotes
Spinner sizexs sm md lg xlmd12–24px, inherits text color
Skeleton circlebooleanfalseRound; no shimmer with reduced motion
TopLoadingBar loadingboolean—Trickle / finish
positionfixed absolutefixedViewport or container top
EmptyState icon / title / description / actionReactNode—Empty-state content

On this page