shadimage
velobits
uibuttonsource: ok · hit

Button

Controls are radius-md. No variant paints --primary as text; `link` uses --primary-text.

npx shadcn add "https://ui.velobits.dev/r/button.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
class-variance-authorityradix-ui

Registry dependencies

https://ui.velobits.dev/r/cn.json

Files1 · source cached 10/8/2026, 2:24:25 PM

registry/velobits/ui/button.tsx
'use client';

import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';

import { cn } from '@/lib/utils';

/**
 * ## Why there is no `primary`-coloured text variant
 *
 * `--primary` (#007ACC) measures 3.90:1 on the cream page , fine as a fill
 * behind white text (4.51:1), failing AA as text. The `link` variant therefore
 * uses `text-link` (`--primary-text`, 5.34:1), and no variant paints
 * `--primary` on a page background.
 *
 * ## Radius
 *
 * Controls are `--radius-md` (6px) system-wide. The Keycloak login theme's
 * vended `button.tsx` hardcodes `rounded-full`, so adopting this shared radius
 * is what makes login buttons stop being pills , an accepted consequence,
 * reversible by overriding `--keycloakify-shadcn-radius`.
 *
 * ## RTL
 *
 * Tailwind v4's `px-*` and `gap-*` are already logical, so nothing here needs
 * mirroring under `dir="rtl"`. A directional icon *child* still does.
 */
const buttonVariants = cva(
  [
    'inline-flex items-center justify-center gap-2 whitespace-nowrap',
    'rounded-md text-sm font-medium',
    // `transition-[colors,transform]`, not `transition-all`: the press below moves
    // a transform, and `all` would also animate width/height when a label changes
    // , which reads as the button inflating rather than as a press.
    'transition-[color,background-color,border-color,transform] duration-micro ease-out',
    // The visible ring comes from the token layer's global :focus-visible rule.
    // This adds the soft halo; `outline-none` only suppresses the UA default.
    'outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40',
    /*
     * THE PRESS. A 1.5% scale-down while the pointer is held.
     *
     * `transform`, deliberately, and not a colour shift: it is the one property
     * that composites on the GPU without invalidating layout or paint, so it stays
     * cheap on a toolbar with twenty buttons in it. A darker fill would need a
     * fourth token per variant and would fight the hover fill on the way in.
     *
     * `motion-reduce:active:scale-100` rather than relying on the token layer's
     * global reduced-motion blanket: that blanket clamps transition DURATION, so
     * without this the press would still happen, just instantly. A user who asked
     * for no motion should get no movement, not faster movement.
     */
    'active:scale-[0.985] motion-reduce:active:scale-100',
    'disabled:pointer-events-none disabled:opacity-50',
    /*
     * `aria-disabled` suppresses the PRESS only , never pointer events, and never
     * the opacity.
     *
     * In this system `aria-disabled` is a message to assistive tech, not a
     * behaviour: `Pagination` marks an unavailable page that way and swallows the
     * click in JS, deliberately keeping the element focusable and hit-testable.
     * `pointer-events-none` here would stop it receiving the very click that guard
     * exists to catch, and suppresses the focus ring on some platforms , so it is
     * `disabled:`-only above, and `pagination.test.tsx` asserts the bare class
     * never reaches an aria-disabled control.
     *
     * The scale is different: an element presenting itself as unavailable should
     * not animate a successful-looking press. Opacity is left to the call site,
     * which is where the "unavailable" styling is decided.
     */
    'aria-disabled:active:scale-100',
    // Size any icon child once, rather than at every call site.
    "[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0",
  ],
  {
    variants: {
      variant: {
        primary: 'bg-primary text-on-primary control-raised hover:bg-primary-hover',
        /**
         * The lime brand fill. `text-on-brand` is charcoal at 10.89:1 , the only
         * sanctioned pairing on lime. White on lime is 1.31:1, which is why no
         * white-on-brand variant exists to reach for by mistake.
         *
         * `hover:bg-brand-hover`, not `hover:brightness-95`. A filter is outside
         * the palette: nothing measures it, and it composites against whatever is
         * behind the button , so the same hover rendered one colour on an opaque
         * panel and another on a glass surface. The token is measured (charcoal on
         * it is 9.73:1, so lime's only legal pairing survives the hover).
         */
        brand: 'bg-brand text-on-brand control-raised hover:bg-brand-hover',
        secondary: 'border border-field-border bg-panel text-fg control-raised hover:bg-highlight',
        ghost: 'text-fg hover:bg-highlight',
        /**
         * `text-on-danger`, NOT `text-on-primary`.
         *
         * This was `text-on-primary` , white , and in dark mode that measured
         * **2.45:1** against AA's 4.5. `--danger` is a LIGHT red in dark mode
         * because the same token also has to work as text on a dark surface, and
         * nothing gated the fill-with-text-on-it combination. `--on-danger` is
         * white in light and charcoal in dark, for the same reason `--on-brand` is
         * charcoal on lime, and it now has its own contrast pair.
         */
        destructive: 'bg-danger text-on-danger control-raised hover:bg-danger-hover',
        link: 'text-link underline-offset-4 hover:underline',
      },
      size: {
        sm: 'h-8 px-3 text-xs',
        md: 'h-9 px-4',
        lg: 'h-10 px-6',
        /** Square, for an icon-only button , which needs an `aria-label`. */
        icon: 'size-9',
      },
    },
    defaultVariants: { variant: 'secondary', size: 'md' },
  },
);

export interface ButtonProps
  extends React.ComponentProps<'button'>, VariantProps<typeof buttonVariants> {
  /**
   * Render the single child element instead of a `<button>`, forwarding these
   * classes onto it , for wrapping a router `<Link>` without nesting an anchor
   * inside a button.
   */
  asChild?: boolean;
}

function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
  const Comp = asChild ? Slot.Root : 'button';
  return (
    // `data-slot` is how shadcn's generated code targets sub-parts; keeping it
    // means anything `npx shadcn add` writes composes with these primitives.
    <Comp
      data-slot="button"
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    />
  );
}

export { Button, buttonVariants };

More from velobits

Browse all components in this library →