Empty
空状态:面板里的分组框,或者 A 的倾斜便签——你的图片从便签上沿探出头,两颗 ✦ 在旁边闪。
还没有追的番,去今日放送里挑一部吧
订阅之后,新的一集会出现在这里。
empty-demo.tsx
import { HeartIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
export default function EmptyDemo() {
return (
<Empty variant="note">
<EmptyHeader>
<EmptyMedia variant="peek">
{/* eslint-disable-next-line @next/next/no-img-element -- your own picture goes here */}
<img src="/brand/mascot.webp" alt="" width={112} height={112} />
</EmptyMedia>
<EmptyTitle>还没有追的番,去今日放送里挑一部吧</EmptyTitle>
<EmptyDescription>订阅之后,新的一集会出现在这里。</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>
<HeartIcon />
去挑番
</Button>
</EmptyContent>
</Empty>
)
}安装
npx shadcn@latest add @anibt/empty第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。
手动安装
- 1. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 安装依赖
npm install class-variance-authority lucide-react - 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
empty-well.tsx
import { RssIcon, SearchXIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
export default function EmptyWell() {
return (
<div className="grid w-full gap-4 md:grid-cols-2">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon />
</EmptyMedia>
<EmptyTitle>没有找到「夜樱终电 第 13 集」</EmptyTitle>
<EmptyDescription>换个关键词,或者去掉「只看有种子」再试试。</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="secondary">清除筛选</Button>
</EmptyContent>
</Empty>
<Empty size="sm">
<EmptyHeader>
<EmptyMedia variant="icon" className="rotate-3">
<RssIcon />
</EmptyMedia>
<EmptyTitle>这个字幕组还没有 RSS 订阅</EmptyTitle>
<EmptyDescription>
订阅后新资源会推到你的阅读器,<a href="#rss">怎么用 RSS</a>
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button size="sm">订阅 RSS</Button>
</EmptyContent>
</Empty>
</div>
)
}默认的 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。

