Button
糖果主按钮、凸起次按钮、安静按钮和危险按钮。变体与尺寸沿用 shadcn 的名字,可以直接替换 components/ui/button.tsx。
button-demo.tsx
import { RssIcon, Trash2Icon, UploadIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function ButtonDemo() {
return (
<div className="flex flex-wrap items-center gap-2">
<Button>
<UploadIcon />
发布资源
</Button>
<Button variant="secondary">
<RssIcon />
订阅 RSS
</Button>
<Button variant="ghost">取消</Button>
<Button variant="destructive">
<Trash2Icon />
删除
</Button>
</div>
)
}安装
npx shadcn@latest add @anibt/button第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。 CLI 会一并装好 @anibt/spinner。
手动安装
- 1. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 安装依赖
npm install class-variance-authority radix-ui - 3. 它还用到
npx shadcn@latest add @anibt/spinner - 4. 把下面的文件放进项目components/ui/button.tsx
import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" import { cn } from "@/lib/utils" import { Spinner } from "@/components/ui/spinner" /** * Button — AniBT 「贴纸手账」. * * The variant and size names are shadcn's, so this file can replace * `components/ui/button.tsx` and every shadcn component that imports * `buttonVariants` keeps compiling. Each variant is one A paint, owned by an * `anibt-btn-*` recipe from `@anibt/theme`: * * - `default` — candy: the one filled action on a view. Pink gradient, white * ink, a gloss on the top half, a deeper edge; hover lifts it and pops a ✦, * press pushes it onto its edge. * - `secondary` / `outline` — raised: paper with an input-coloured ring and a * 1.5px bottom edge; hover lifts it and turns label and ring pink. * - `ghost` — quiet: nothing until the pointer is on it, then the pink wash. * - `destructive` — danger: red tint, red ink, red edge, the raised lift. * - `link` — the pink word, underlined on hover. * - `field` — a picker trigger (combobox) that reads as a field, not a key. * * Sizes are A's control heights: 32 (`default`), 28 (`sm`), 36 (`lg`), and * the matching icon squares; `xs` / `icon-xs` (24) exist for shadcn parity. * * States, the same in 白樱 and 夜樱 because every colour is a token: * focus-visible is a 2px ring-colour outline 2px outside the box (it never * replaces the paint's own edge); disabled is flat muted; `loading` sets * `aria-busy`, blocks interaction and overlays a spinner without resizing * the button — every disabled paint is gated on `not-aria-busy`, so work in * flight keeps its colour. */ const LINK = "bg-transparent text-accent-foreground hover:underline hover:underline-offset-4" const DISABLED_FLAT = "disabled:not-aria-busy:bg-muted aria-disabled:not-aria-busy:bg-muted" const FIELD = "bg-card font-normal text-foreground inset-ring-1 inset-ring-input hover:inset-ring-anibt-wash-line aria-expanded:inset-ring-[1.5px] aria-expanded:inset-ring-ring disabled:not-aria-busy:bg-muted" const buttonVariants = cva( "relative inline-flex shrink-0 cursor-pointer items-center justify-center rounded-[10px] text-[13px] leading-none font-semibold whitespace-nowrap outline-hidden select-none aria-busy:cursor-wait disabled:pointer-events-none disabled:not-aria-busy:text-muted-foreground disabled:not-aria-busy:shadow-none aria-disabled:pointer-events-none aria-disabled:not-aria-busy:text-muted-foreground aria-disabled:not-aria-busy:shadow-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid aria-invalid:outline-2 aria-invalid:outline-anibt-danger-fill aria-invalid:outline-solid [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "anibt-btn-primary", secondary: `anibt-btn-raised ${DISABLED_FLAT}`, outline: `anibt-btn-raised ${DISABLED_FLAT}`, ghost: "anibt-btn-ghost", destructive: `anibt-btn-danger ${DISABLED_FLAT}`, link: LINK, field: FIELD, }, size: { default: "h-8 gap-2 px-4 has-[>svg]:px-3.5", xs: "h-6 gap-1 px-2 text-[12px] has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1.5 px-3 text-[12px] has-[>svg]:px-2.5", lg: "h-9 gap-2 px-5 text-[14px] has-[>svg]:px-4", icon: "size-8", "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-7", "icon-lg": "size-9", }, }, compoundVariants: [ // A link is a word, whatever size it was given: no box, no padding. // Compound classes come after the size's, so they win the merge. { variant: "link", class: "h-auto px-0 has-[>svg]:px-0" }, ], defaultVariants: { variant: "default", size: "default", }, } ) type ButtonProps = React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { /** Render the single child element (a link, say) with the button's paint. */ asChild?: boolean /** * Base UI's form of `asChild`, so the components of shadcn's `base-*` * styles that render a button as another element keep working: * `render={<a href="…" />}`. The element takes the button's paint and * props; `children` go inside it. */ render?: React.ReactElement /** * Base UI compatibility: `false` when `render` is not a `<button>`. It only * decides whether `type` and `disabled` are passed to the rendered element. */ nativeButton?: boolean /** Busy: shows the spinner, sets `aria-busy` and blocks interaction. */ loading?: boolean /** * Replaces the label while busy. Without it the label keeps its box and * the spinner is painted over it, so the button never changes width. */ loadingText?: React.ReactNode } function Button({ className, variant = "default", size = "default", asChild = false, render, nativeButton = true, type = "button", loading = false, loadingText, disabled, children, ...props }: ButtonProps) { const disabledNow = Boolean(disabled || loading) const overlaySpinner = loading && loadingText === undefined const classes = cn(buttonVariants({ variant, size, className })) if (asChild || render) { // A rendered <button> still gets its type and real `disabled`; anything // else (a link) is marked `aria-disabled` instead. const native = Boolean(render) && nativeButton const child = render && children !== undefined ? React.cloneElement(render, undefined, children) : (render ?? children) return ( <Slot.Root data-slot="button" data-variant={variant} data-size={size} aria-busy={loading || undefined} aria-disabled={!native && disabledNow ? true : undefined} className={classes} {...(native ? { type, disabled: disabledNow } : {})} {...props} > {child} </Slot.Root> ) } return ( <button data-slot="button" data-variant={variant} data-size={size} aria-busy={loading || undefined} type={type} disabled={disabledNow} className={classes} {...props} > {overlaySpinner ? ( <> <Spinner className="absolute inset-0 m-auto size-4" /> {/* `opacity-0`, not `invisible`: the label stays in the accessibility tree, so the button keeps its name. */} <span className="inline-flex items-center gap-[inherit] opacity-0">{children}</span> </> ) : ( <> {loading ? <Spinner className="size-4" /> : null} {loading ? loadingText : children} </> )} </button> ) } export { Button, buttonVariants } export type { ButtonProps }
用法
components/publish-bar.tsx
import { Button } from "@/components/ui/button"
export function PublishBar() {
return (
<div className="flex gap-2">
<Button variant="ghost">取消</Button>
<Button>发布资源</Button>
</div>
)
}一屏只放一个糖果主按钮(default);其余操作用 secondary 或 ghost。
变体
button-variants.tsx
import { ChevronsUpDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function ButtonVariants() {
return (
<div className="flex flex-wrap items-center gap-2">
<Button>default</Button>
<Button variant="secondary">secondary</Button>
<Button variant="outline">outline</Button>
<Button variant="ghost">ghost</Button>
<Button variant="destructive">destructive</Button>
<Button variant="link">link</Button>
<Button variant="field" className="w-36 justify-between">
简体中文
<ChevronsUpDownIcon className="text-muted-foreground" />
</Button>
</div>
)
}default:糖果。粉色渐变、白字、上半高光、深一档的底边;悬停上浮 1px 并冒出一颗 ✦,按下压回底边。secondary/outline:凸起。纸面加一圈边和 1.5px 底边;悬停上浮,字和边变粉。两者相同,outline只为兼容 shadcn 的写法。ghost:安静。静止时没有边和底,指针放上去才出现粉色淡底。destructive:危险。红色淡底、红字、红边,抬起和按下同凸起一档。link:粉色文字,悬停出现下划线。field:打开选择器的按钮(组合框的触发器),长得像输入框而不是按键。
尺寸
button-sizes.tsx
import { DownloadIcon, HeartIcon, MoreHorizontalIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function ButtonSizes() {
return (
<div className="flex flex-col items-start gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button size="sm">小号 28</Button>
<Button>默认 32</Button>
<Button size="lg">大号 36</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="icon-sm" variant="ghost" aria-label="更多">
<MoreHorizontalIcon />
</Button>
<Button size="icon" variant="secondary" aria-label="下载种子">
<DownloadIcon />
</Button>
<Button size="icon-lg" aria-label="收藏">
<HeartIcon />
</Button>
</div>
</div>
)
}控件高 32(default)、28(sm)、36(lg),图标方块是对应的 icon 32、icon-sm 28、icon-lg 36。xs 和 icon-xs(24)只为对齐 shadcn。手机上需要 44px 触控区时,在调用处自己加高。
加载中
button-loading.tsx
"use client"
import { RefreshCwIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
export default function ButtonLoading() {
const [saving, setSaving] = useState(false)
const [syncing, setSyncing] = useState(false)
function run(set: (busy: boolean) => void) {
set(true)
setTimeout(() => set(false), 1800)
}
return (
<div className="flex flex-wrap items-center gap-2">
{/* No loadingText: the label keeps its box, the spinner sits on top. */}
<Button loading={saving} onClick={() => run(setSaving)}>
保存设置
</Button>
{/* With loadingText: the copy changes and the spinner sits beside it. */}
<Button variant="secondary" loading={syncing} loadingText="同步中…" onClick={() => run(setSyncing)}>
<RefreshCwIcon />
立即同步
</Button>
<Button disabled>不可用</Button>
<Button variant="secondary" disabled>
不可用
</Button>
</div>
)
}loading 会设置 aria-busy 并禁用按钮,但不会把按钮变灰:进行中的操作保留自己的颜色。
- 不给
loadingText:标签保留原来的宽度,转圈叠在上面,按钮不会变宽变窄。 - 给
loadingText:文字换成它,转圈放在旁边。
作为链接
button-as-child.tsx
import { ArrowUpRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function ButtonAsChild() {
return (
<Button variant="secondary" asChild>
<a href="https://anibt.net" target="_blank" rel="noreferrer">
打开 AniBT
<ArrowUpRightIcon />
</a>
</Button>
)
}asChild 把样式交给唯一的子元素(链接、路由的 Link)。禁用时会给子元素加上 aria-disabled。
属性
Button
variant"default" | "secondary" | "outline" | "ghost" | "destructive" | "link" | "field"默认 "default"材质,见上文
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg"默认 "default"控件高 32 / 24 / 28 / 36,icon 系列是方块
loadingboolean默认 false进行中:显示转圈、设置 aria-busy、阻止交互
loadingTextReactNode默认 —进行中替换标签的文字;不给则保留标签宽度、转圈叠在上面
asChildboolean默认 false把样式和属性交给唯一的子元素(Radix Slot)
type"button" | "submit" | "reset"默认 "button"默认是 button,不会意外提交表单
其余属性透传给 <button>。另外导出 buttonVariants(...),给需要按钮样子的其他元素用。
状态
焦点是一圈 2px 的 ring 色轮廓,外偏 2px,不会盖掉按钮自己的底边。禁用是平的 muted 底和弱字。所有颜色都来自 token,白樱和夜樱里是同一套状态。
