Renders a button or a component that looks like a button.
Installation
npx shadcn@latest add @caprice/buttonnpm install @caprice-ui/reactInstall the following dependencies:
npm install @base-ui/react lucide-reactAdd a cn helper
import { defineConfig, type VariantProps } from 'cva';import { twMerge } from 'tailwind-merge';export const { compose, cva, cx: cn,} = defineConfig({ hooks: { onComplete: (className: string) => twMerge(className), },});export type { VariantProps };Copy and paste the following code into your project.
'use client';import { Button as ButtonPrimitive } from '@base-ui/react/button';import { mergePropsN } from '@base-ui/react/merge-props';import { type PressEvents, usePress } from 'react-aria';import { cva, type VariantProps } from '@/lib/utils';export const buttonVariants = cva({ base: [ "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm outline-none transition-all aria-invalid:border-destructive aria-invalid:outline-destructive/80 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", // Disabled 'data-disabled:pointer-events-none data-disabled:opacity-50', // Focus visible 'focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2', // Transition 'transform-gpu transition-[background-color,box-shadow,scale] data-pressed:scale-[0.98] motion-reduce:transition-none', // Pressed 'data-pressed:inset-shadow-[0_0_0_100vmax_oklch(1_0_0/0.08)] dark:data-pressed:inset-shadow-[0_0_0_100vmax_oklch(0_0_0/0.08)]', ], variants: { variant: { primary: 'bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:outline-primary/80', destructive: 'bg-destructive text-white hover:bg-destructive/90 focus-visible:outline-destructive/80 dark:bg-destructive/60 dark:focus-visible:outline-destructive/60', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-3', md: 'h-9 px-4 py-2 has-[>svg]:px-3', lg: 'h-10 px-6 has-[>svg]:px-4', xl: 'h-12 px-8 text-base has-[>svg]:px-4', '2xl': 'h-14 px-10 text-xl has-[>svg]:px-6', icon: 'size-9', 'icon-sm': 'size-8', 'icon-lg': 'size-10', 'icon-xl': 'size-12', 'icon-2xl': 'size-14', }, }, defaultVariants: { variant: 'primary', size: 'md', }, compoundVariants: [ { size: ['xl', 'icon-xl'], className: "[&_svg:not([class*='size-'])]:size-5", }, { size: ['2xl', 'icon-2xl'], className: "[&_svg:not([class*='size-'])]:size-6", }, ],});/** * A button component that can be used to trigger actions. * Renders a `<button>` element. * * Documentation: [Caprice UI Button](https://caprice-ui.com/docs/components/button) * * API Reference: [Base UI Button](https://base-ui.com/react/components/button#api-reference) */export namespace Button { export type State = ButtonPrimitive.State; export type Variants = { /** * Size * @default 'md' */ size?: VariantProps<typeof buttonVariants>['size']; /** * Variant * @default 'primary' */ variant?: VariantProps<typeof buttonVariants>['variant']; }; export type Props = React.ComponentProps<typeof ButtonPrimitive> & PressEvents & Button.Variants;}export function Button({ variant, size, className, // PressEvents onPress, onPressStart, onPressChange, onPressEnd, onPressUp, onClick, ...props}: Button.Props) { const { pressProps, isPressed } = usePress({ onPress, onPressStart, onPressChange, onPressEnd, onPressUp, onClick, isDisabled: props.disabled, }); const defaultProps: Button.Props & { 'data-slot'?: string } = { 'data-slot': 'button', className: buttonVariants({ variant, size, className }), }; return ( <ButtonPrimitive data-pressed={isPressed ? '' : undefined} {...mergePropsN([defaultProps, pressProps, props])} /> );}Update the import paths to match your project setup.
Usage
import { Button } from "@/components/caprice-ui/button"import { Button } from "@caprice-ui/react/button"<Button variant="outline">Button</Button>Examples
Rendering as another tag
You can use the render prop to make another component look like a button. Here's an example of a link that looks like a button.
import { Button } from '@caprice-ui/react/button';import Link from 'next/link';export default function ButtonRenderLink() { return ( <Button nativeButton={false} render={<Link href="/login" />}> Login </Button> );}The button can remain keyboard accessible while being rendered as another tag, such as a <div>, by specifying nativeButton={false}.
import { Button } from '@/components/caprice-ui/button';
<Button render={<div />} nativeButton={false}>
Button that can contain complex children
</Button>Focusable When Disabled
For buttons that enter a loading state after being clicked, specify the focusableWhenDisabled prop to ensure focus remains on the button when it becomes disabled. This prevents focus from being lost and maintains the tab order.
Sizes
Variant
Primary (default)
Outline
Secondary
Ghost
Destructive
Link
Icon
With Icon
The spacing between the icon and the text is automatically adjusted based on the size of the button. You do not need any margin on the icon.
Rounded
Use the rounded-full class to make the button rounded.
Spinner
Full Width
Differences with shadcn/ui / Radix
If you're familiar with Radix UI and shadcn/ui, most patterns will carry over. This guide points out the differences so you can start using Caprice UI without surprises.
Key changes
| Feature | shadcn/ui | Caprice UI |
|---|---|---|
| Primitive | Radix Slot + native <button> | Base UI Button |
| Composition | asChild prop | render prop (see example) |
| Sizes | default, sm, lg, icon, icon-sm, icon-lg | sm, md, lg, xl, 2xl, icon, icon-sm, icon-lg, icon-xl, icon-2xl |
| Default variant | default | primary |
| Default size | default | md |
| Press events | Standard onClick only | onPress, onPressStart, onPressChange, onPressEnd, onPressUp |
| Pressed state | None | Scale + inset shadow |
| Disabled attribute | :disabled | data-disabled |
| Focusable when disabled | No | Possible (see example) |
| Focus indicator | Ring (ring-[3px]) | Outline (outline-2) |
The xl and 2xl sizes (48px & 56px) are designed with accessibility in mind. They exceed WCAG 2.1's 44×44px minimum touch target, match Material Design's 48dp guideline, and reduce missed taps for users with motor impairments or limited dexterity.
Use them for primary CTAs, mobile interfaces, or anywhere a larger hit area improves the experience.
Alternatively, you can check the touch-hitbox utility for a more flexible approach.
Comparison Example
<Button variant="default" size="default" asChild>
<Link href="/login">Login</Link>
</Button><Button variant="primary" size="md" render={<Link href="/login" />} nativeButton={false}>
Login
</Button>Accessibility
- Icon-only buttons require an
aria-labelto provide an accessible name. Consider wrapping with aTooltipfor additional context. - Loading states: When the button enters a loading state, use the
focusableWhenDisabledprop to maintain focus and prevent the user from losing their place in the tab order. This automatically appliesaria-disabled="true"to communicate the unavailable state to assistive technologies. - Keyboard interaction: The
<button>element responds to both Space and Enter keys. When rendered as<a>, only Enter triggers the action. - Disabled buttons: The component uses
data-disabledfor styling and can optionally remain focusable viafocusableWhenDisabled, allowing screen reader users to discover why a button is unavailable.
API Reference
Prop
Type