AniBTUI
✦ 组件@anibt/emptyregistry JSON

Empty

空状态:面板里的分组框,或者 A 的倾斜便签——你的图片从便签上沿探出头,两颗 ✦ 在旁边闪。

还没有追的番,去今日放送里挑一部吧
订阅之后,新的一集会出现在这里。

安装

npx shadcn@latest add @anibt/empty

第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。

手动安装
  1. 1. 先装主题组件只读 @anibt/theme 的 token 和配方,没有主题就没有样子。
    npx shadcn@latest add @anibt/theme
  2. 2. 安装依赖
    npm install class-variance-authority lucide-react
  3. 3. 把下面的文件放进项目
    components/ui/empty.tsx
    import * as React from "react"
    import { cva, type VariantProps } from "class-variance-authority"
    import { HeartIcon } from "lucide-react"
    
    import { cn } from "@/lib/utils"
    
    /**
     * Empty — the AniBT 「贴纸手账」 empty state.
     *
     * Three frames:
     * - `well` (default): a group inside a panel — the 1px edge, radius 10,
     *   centred copy. The everyday "nothing here yet".
     * - `note`: A's empty state — a paper note tilted −1°, on the pop shadow, with
     *   a picture peeking over its top edge (`EmptyMedia variant="peek"`) between
     *   two twinkling ✦. For the one big empty on a page.
     * - `plain`: no frame, for a place that already has one.
     *
     * The media slot takes an icon (`variant="icon"`: a small white sticker,
     * tilted, the glyph in pink), a picture (`variant="image"`), or the peeking
     * picture of a note (`variant="peek"`). Bring your own picture — a mascot, an
     * illustration; the component draws the frame, never the art.
     *
     * The parts are shadcn's (`Empty`, `EmptyHeader`, `EmptyMedia`, `EmptyTitle`,
     * `EmptyDescription`, `EmptyContent`).
     */
    const emptyVariants = cva("flex min-w-0 flex-col items-center text-center text-balance", {
      variants: {
        variant: {
          well: "anibt-well w-full flex-1 justify-center gap-4 px-6",
          note: "w-full flex-1 justify-center px-4",
          plain: "w-full flex-1 justify-center gap-4 px-6",
        },
        size: {
          default: "",
          sm: "",
        },
      },
      compoundVariants: [
        { variant: "well", size: "default", class: "py-10" },
        { variant: "well", size: "sm", class: "py-6" },
        { variant: "plain", size: "default", class: "py-10" },
        { variant: "plain", size: "sm", class: "py-6" },
        { variant: "note", size: "default", class: "pt-28 pb-10" },
        { variant: "note", size: "sm", class: "pt-24 pb-6" },
      ],
      defaultVariants: {
        variant: "well",
        size: "default",
      },
    })
    
    function Empty({
      className,
      variant,
      size,
      children,
      ...props
    }: React.ComponentProps<"div"> & VariantProps<typeof emptyVariants>) {
      const frame = variant ?? "well"
      return (
        <div
          data-slot="empty"
          data-variant={frame}
          data-size={size ?? "default"}
          className={cn(emptyVariants({ variant, size }), className)}
          {...props}
        >
          {frame === "note" ? (
            <div
              data-slot="empty-note"
              className="anibt-pop relative flex w-full max-w-[340px] -rotate-1 flex-col items-center gap-3 px-4 pt-5 pb-4 transition-transform duration-500 ease-anibt-spring motion-safe:hover:rotate-0"
            >
              {children}
            </div>
          ) : (
            children
          )}
        </div>
      )
    }
    
    function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
      return (
        <div
          data-slot="empty-header"
          className={cn("flex max-w-sm flex-col items-center gap-1.5 text-center", className)}
          {...props}
        />
      )
    }
    
    const emptyMediaVariants = cva(
      "relative flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
      {
        variants: {
          variant: {
            default: "mb-1 bg-transparent text-muted-foreground",
            // A's section mark: a small white sticker, tilted, the glyph in pink.
            icon: "anibt-rim mb-2 size-10 -rotate-3 rounded-[10px] bg-card text-anibt-mark [&_svg:not([class*='size-'])]:size-5",
            image: "mb-2 [&_img]:h-auto [&_img]:max-w-full",
            // Peeks over the top edge of an `Empty variant="note"`: the picture
            // stands on the note's edge and its lower 28px are hidden behind it.
            peek: "pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 translate-y-7 [clip-path:inset(-64px_-64px_28px_-64px)] [&_img]:block [&_img]:max-w-none",
          },
        },
        defaultVariants: {
          variant: "default",
        },
      }
    )
    
    function EmptyMedia({
      className,
      variant = "default",
      sparkles,
      children,
      ...props
    }: React.ComponentProps<"div"> &
      VariantProps<typeof emptyMediaVariants> & {
        /** The ✦ ✦ ♥ around a peeking picture. Default on for `peek`, off otherwise. */
        sparkles?: boolean
      }) {
      const decorate = sparkles ?? variant === "peek"
      return (
        <div
          data-slot="empty-icon"
          data-variant={variant}
          aria-hidden={variant === "peek" ? true : undefined}
          className={cn(emptyMediaVariants({ variant }), className)}
          {...props}
        >
          {variant === "peek" ? <div className="animate-anibt-peek">{children}</div> : children}
          {decorate ? <EmptySparkles /> : null}
        </div>
      )
    }
    
    /** Two ✦ and a small heart round the media. Decorative. */
    function EmptySparkles() {
      const sparkle =
        "pointer-events-none absolute inline-block animate-anibt-twinkle [-webkit-mask:var(--anibt-sparkle)_center/contain_no-repeat] [mask:var(--anibt-sparkle)_center/contain_no-repeat]"
      return (
        <>
          <span aria-hidden="true" className={cn(sparkle, "top-5 -left-7 size-4 bg-anibt-mark")} />
          <span
            aria-hidden="true"
            className={cn(sparkle, "top-1 -right-6 size-3 bg-anibt-time [animation-delay:0.8s]")}
          />
          <HeartIcon
            aria-hidden="true"
            className="pointer-events-none absolute top-12 -right-9 size-3 animate-anibt-bob fill-current text-anibt-mark"
          />
        </>
      )
    }
    
    /** 15/700 ink. */
    function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
      return (
        <div
          data-slot="empty-title"
          className={cn("text-[15px] leading-[1.45] font-bold text-foreground", className)}
          {...props}
        />
      )
    }
    
    /** 13, muted; links underline and turn pink. */
    function EmptyDescription({ className, ...props }: React.ComponentProps<"div">) {
      return (
        <div
          data-slot="empty-description"
          className={cn(
            "text-[13px] leading-relaxed text-muted-foreground [&>a]:text-accent-foreground [&>a]:underline [&>a]:underline-offset-4",
            className
          )}
          {...props}
        />
      )
    }
    
    /** The actions: one candy button at most, a quiet one beside it. */
    function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
      return (
        <div
          data-slot="empty-content"
          className={cn(
            "flex w-full max-w-sm min-w-0 flex-wrap items-center justify-center gap-2 text-[13px] text-balance",
            className
          )}
          {...props}
        />
      )
    }
    
    export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia, emptyVariants }

用法

components/no-subscriptions.tsx
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"

export function NoSubscriptions() {
  return (
    <Empty variant="note">
      <EmptyHeader>
        <EmptyMedia variant="peek">
          <img src="/mascot.webp" alt="" width={112} height={112} />
        </EmptyMedia>
        <EmptyTitle>还没有追的番</EmptyTitle>
        <EmptyDescription>订阅之后,新的一集会出现在这里。</EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button>去挑番</Button>
      </EmptyContent>
    </Empty>
  )
}

variant="note" 是一页里那个最大的空:一张倾斜 −1° 的纸便签,浮层阴影,指针放上去会摆正。EmptyMedia variant="peek" 把你的图片立在便签上沿,下面 28px 藏在便签后面,并且隔几秒探一下头;两颗 ✦ 和一颗小心是装饰,sparkles={false} 可以去掉。

组件只画框,不带图:吉祥物、插画都是你自己的。示例里的看板娘是 AniBT 的品牌素材,不随组件分发。

分组框

没有找到「夜樱终电 第 13 集」
换个关键词,或者去掉「只看有种子」再试试。
这个字幕组还没有 RSS 订阅
订阅后新资源会推到你的阅读器,怎么用 RSS

默认的 variant="well" 是面板里的一个分组:1px 边、圆角 10、内容居中,size="sm" 收紧上下留白。EmptyMedia variant="icon" 是 A 的小节记号:一枚倾斜的白色贴纸,图标是粉色。variant="plain" 不画框,给本身已经有框的地方。

文案说清楚「这里为什么空」和「下一步做什么」;按钮最多一个糖果主按钮。

属性

Empty
variant"well" | "note" | "plain"默认 "well"分组框 / 倾斜便签 / 不画框
size"default" | "sm"默认 "default"上下留白
EmptyMedia
variant"default" | "icon" | "image" | "peek"默认 "default"图标 / 贴纸图标 / 图片 / 从便签上沿探头的图片
sparklesboolean默认 peek 时为 true两颗 ✦ 和一颗小心

EmptyHeader、EmptyTitle、EmptyDescription、EmptyContent 与 shadcn 同名,属性透传给 div。