AniBTUI
✦ 组件@anibt/buttonregistry JSON

Button

糖果主按钮、凸起次按钮、安静按钮和危险按钮。变体与尺寸沿用 shadcn 的名字,可以直接替换 components/ui/button.tsx。

安装

npx shadcn@latest add @anibt/button

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

手动安装
  1. 1. 先装主题组件只读 @anibt/theme 的 token 和配方,没有主题就没有样子。
    npx shadcn@latest add @anibt/theme
  2. 2. 安装依赖
    npm install class-variance-authority radix-ui
  3. 3. 它还用到
    npx shadcn@latest add @anibt/spinner
  4. 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。

变体

  • default:糖果。粉色渐变、白字、上半高光、深一档的底边;悬停上浮 1px 并冒出一颗 ✦,按下压回底边。
  • secondary / outline:凸起。纸面加一圈边和 1.5px 底边;悬停上浮,字和边变粉。两者相同,outline 只为兼容 shadcn 的写法。
  • ghost:安静。静止时没有边和底,指针放上去才出现粉色淡底。
  • destructive:危险。红色淡底、红字、红边,抬起和按下同凸起一档。
  • link:粉色文字,悬停出现下划线。
  • field:打开选择器的按钮(组合框的触发器),长得像输入框而不是按键。

尺寸

控件高 32(default)、28(sm)、36(lg),图标方块是对应的 icon 32、icon-sm 28、icon-lg 36。xs 和 icon-xs(24)只为对齐 shadcn。手机上需要 44px 触控区时,在调用处自己加高。

加载中

loading 会设置 aria-busy 并禁用按钮,但不会把按钮变灰:进行中的操作保留自己的颜色。

  • 不给 loadingText:标签保留原来的宽度,转圈叠在上面,按钮不会变宽变窄。
  • 给 loadingText:文字换成它,转圈放在旁边。

作为链接

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,白樱和夜樱里是同一套状态。