Button
Impulsar acción del usuario. Variantes de alta conversión. Estados claros.
Workspace name
Acme Studio
1import { Button } from '@/components/ui/button';2import { Flex } from '@/components/ui/flex';34export function Default() {5 return (6 <Flex7 align="center"8 justify="between"9 gap="4"10 className="border-border bg-card w-full max-w-sm rounded-xl border p-4"11 >12 <div className="min-w-0">13 <p className="text-sm font-medium">Workspace name</p>14 <p className="text-muted-foreground truncate text-xs">Acme Studio</p>15 </div>16 <Button>Save changes</Button>17 </Flex>18 );19}
Instalación
Copia y pega el siguiente código en tu proyecto.
1'use client';23import { cva, type VariantProps } from 'class-variance-authority';4import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from 'motion/react';5import React from 'react';6import { cn } from '../lib/cn';7import { springs, tap } from '../lib/motion';89type IconProps = React.SVGProps<SVGSVGElement> & {10 size?: number | string;11};1213function LoaderIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {14 return (15 <svg16 xmlns="http://www.w3.org/2000/svg"17 width={size}18 height={size}19 viewBox="0 0 24 24"20 fill="none"21 stroke="currentColor"22 strokeWidth={strokeWidth}23 strokeLinecap="round"24 strokeLinejoin="round"25 aria-hidden="true"26 {...props}27 >28 <path d="M21.5 12A9.5 9.5 0 1 1 12 2.5" />29 </svg>30 );31}3233const buttonVariants = cva(34 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none select-none relative overflow-hidden ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-40 aria-disabled:pointer-events-none aria-disabled:opacity-40 aria-invalid:ring-1 aria-invalid:ring-destructive/50 aria-invalid:border-destructive transition-colors',35 {36 variants: {37 variant: {38 default: 'bg-primary text-primary-foreground hover:bg-primary/90',39 destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',40 outline: 'border-border border bg-transparent hover:bg-muted text-foreground',41 secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',42 ghost: 'hover:bg-muted text-foreground',43 link: 'text-foreground underline-offset-4 hover:underline',44 },45 size: {46 default: 'h-9 px-4 py-2',47 sm: 'h-8 px-3 text-xs',48 lg: 'h-10 px-6 text-base',49 icon: 'size-9',50 },51 },52 compoundVariants: [{ variant: 'link', className: 'h-auto p-0' }],53 defaultVariants: {54 variant: 'default',55 size: 'default',56 },57 },58);5960const LOADER_VARIANTS = {61 initial: { opacity: 0, scale: 0.8, filter: 'blur(4px)' },62 animate: { opacity: 1, scale: 1, filter: 'blur(0px)' },63 exit: { opacity: 0, scale: 0.8, filter: 'blur(4px)' },64} as const;6566const CONTENT_VARIANTS = {67 initial: { opacity: 0, y: 5, filter: 'blur(4px)' },68 animate: { opacity: 1, y: 0, filter: 'blur(0px)' },69 exit: { opacity: 0, y: -5, filter: 'blur(4px)' },70} as const;7172const REDUCED_SWAP_VARIANTS = {73 initial: { opacity: 0 },74 animate: { opacity: 1 },75 exit: { opacity: 0 },76} as const;7778const SWAP_TRANSITION = { duration: 0.2 } as const;79const BUTTON_STYLE = { willChange: 'transform' } as const;8081interface ButtonProps extends HTMLMotionProps<'button'>, VariantProps<typeof buttonVariants> {82 loading?: boolean;83 loader?: React.ReactNode;84 leftIcon?: React.ReactNode;85 rightIcon?: React.ReactNode;86 fullWidth?: boolean;87 children?: React.ReactNode;88}8990const ButtonRoot = ({91 className,92 variant = 'default',93 size = 'default',94 loading = false,95 loader,96 leftIcon,97 rightIcon,98 fullWidth = false,99 disabled,100 children,101 type = 'button',102 onClick,103 ref,104 ...props105}: ButtonProps) => {106 const isDisabled = disabled || loading;107 const shouldReduceMotion = useReducedMotion();108109 return (110 <motion.button111 ref={ref}112 type={type}113 disabled={disabled}114 aria-disabled={loading && !disabled ? true : undefined}115 aria-busy={loading}116 onClick={(event) => {117 if (loading) {118 event.preventDefault();119 return;120 }121 onClick?.(event);122 }}123 className={cn(buttonVariants({ variant, size }), fullWidth && 'w-full', className)}124 whileTap={!isDisabled && !shouldReduceMotion ? tap : undefined}125 transition={springs.snappy}126 style={BUTTON_STYLE}127 {...props}128 >129 <motion.span130 className="flex items-center gap-2"131 variants={shouldReduceMotion ? REDUCED_SWAP_VARIANTS : CONTENT_VARIANTS}132 initial={false}133 animate={loading ? 'exit' : 'animate'}134 transition={SWAP_TRANSITION}135 >136 {leftIcon ? <span className="shrink-0">{leftIcon}</span> : null}137 <span>{children}</span>138 {rightIcon ? <span className="shrink-0">{rightIcon}</span> : null}139 </motion.span>140 <AnimatePresence initial={false}>141 {loading ? (142 <motion.span143 key="loader"144 variants={shouldReduceMotion ? REDUCED_SWAP_VARIANTS : LOADER_VARIANTS}145 initial="initial"146 animate="animate"147 exit="exit"148 transition={SWAP_TRANSITION}149 className="absolute inset-0 flex items-center justify-center"150 aria-hidden="true"151 >152 {loader ?? (153 <LoaderIcon className="size-4 animate-spin motion-reduce:animate-none" size={16} />154 )}155 </motion.span>156 ) : null}157 </AnimatePresence>158 </motion.button>159 );160};161162ButtonRoot.displayName = 'Button';163164interface ButtonGroupProps extends React.HTMLAttributes<HTMLDivElement> {165 children: React.ReactNode;166 orientation?: 'horizontal' | 'vertical';167 attached?: boolean;168 ref?: React.Ref<HTMLDivElement>;169}170171const ButtonGroup = ({172 className,173 children,174 orientation = 'horizontal',175 attached = false,176 ref,177 ...props178}: ButtonGroupProps) => {179 return (180 <div181 ref={ref}182 role="group"183 className={cn(184 'inline-flex',185 orientation === 'horizontal' ? 'flex-row' : 'flex-col',186 attached187 ? orientation === 'horizontal'188 ? '[&>button:not(:first-child)]:-ml-px [&>button:not(:first-child)]:rounded-l-none [&>button:not(:last-child)]:rounded-r-none'189 : '[&>button:not(:first-child)]:-mt-px [&>button:not(:first-child)]:rounded-t-none [&>button:not(:last-child)]:rounded-b-none'190 : 'gap-2',191 className,192 )}193 {...props}194 >195 {children}196 </div>197 );198};199200ButtonGroup.displayName = 'ButtonGroup';201202const Button = Object.assign(ButtonRoot, {203 Group: ButtonGroup,204});205206export { Button, buttonVariants };207export type { ButtonGroupProps, ButtonProps };
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Anatomía
1import { Button } from '@/components/ui/button';
1<Button variant="outline">Button</Button>
Variantes
Tamaños
Table row
Invoice #2041
Northwind Labs, $1,280
Form footer
Billing address
Updated 3 days ago
Pricing page
Team plan
$24 per seat, billed monthly
Toolbar
Search this project
Icon size keeps the button square
1import { Button } from '@/components/ui/button';2import { PlusIcon } from '@/components/ui/plus';3import { SearchIcon } from '@/components/ui/search';4import { Flex } from '@/components/ui/flex';5import { Stack } from '@/components/ui/stack';67const rows = [8 {9 size: 'sm' as const,10 place: 'Table row',11 title: 'Invoice #2041',12 detail: 'Northwind Labs, $1,280',13 action: 'Mark as paid',14 },15 {16 size: 'default' as const,17 place: 'Form footer',18 title: 'Billing address',19 detail: 'Updated 3 days ago',20 action: 'Save changes',21 },22 {23 size: 'lg' as const,24 place: 'Pricing page',25 title: 'Team plan',26 detail: '$24 per seat, billed monthly',27 action: 'Start free trial',28 },29];3031export function Sizes() {32 return (33 <Stack gap="3" className="w-full max-w-md">34 {rows.map((row) => (35 <Flex36 key={row.size}37 align="center"38 justify="between"39 gap="4"40 className="border-border bg-card rounded-xl border p-4"41 >42 <div className="min-w-0">43 <p className="text-muted-foreground text-[11px] tracking-wide uppercase">{row.place}</p>44 <p className="mt-1 truncate text-sm font-medium">{row.title}</p>45 <p className="text-muted-foreground truncate text-xs">{row.detail}</p>46 </div>47 <Button48 size={row.size}49 leftIcon={50 <PlusIcon51 className={row.size === 'lg' ? 'size-5' : 'size-4'}52 size={row.size === 'lg' ? 20 : 16}53 />54 }55 >56 {row.action}57 </Button>58 </Flex>59 ))}60 <Flex61 align="center"62 justify="between"63 gap="4"64 className="border-border bg-card rounded-xl border p-4"65 >66 <div className="min-w-0">67 <p className="text-muted-foreground text-[11px] tracking-wide uppercase">Toolbar</p>68 <p className="mt-1 truncate text-sm font-medium">Search this project</p>69 <p className="text-muted-foreground truncate text-xs">70 Icon size keeps the button square71 </p>72 </div>73 <Button size="icon" aria-label="Search this project">74 <SearchIcon className="size-4" size={16} />75 </Button>76 </Flex>77 </Stack>78 );79}
Secondary
Seats
7 of 10 used on the Team plan
1import { Button } from '@/components/ui/button';2import { UserPlusIcon } from '@/components/ui/user-plus';3import { Flex } from '@/components/ui/flex';45export function Secondary() {6 return (7 <Flex8 align="center"9 justify="between"10 gap="4"11 className="border-border bg-card w-full max-w-md rounded-xl border p-4"12 >13 <div className="min-w-0">14 <p className="text-sm font-medium">Seats</p>15 <p className="text-muted-foreground text-xs">7 of 10 used on the Team plan</p>16 </div>17 <Button variant="secondary" leftIcon={<UserPlusIcon className="size-4" size={16} />}>18 Invite teammate19 </Button>20 </Flex>21 );22}
Destructive
Delete billing account
Removes 3 projects and every invoice from March 2024 onward. This cannot be undone.
1import { Button } from '@/components/ui/button';2import { TrashIcon } from '@/components/ui/trash';3import { Stack } from '@/components/ui/stack';45export function Destructive() {6 return (7 <Stack8 gap="3"9 className="border-destructive-border bg-destructive-surface w-full max-w-sm rounded-xl border p-4"10 >11 <div>12 <p className="text-destructive-text text-sm font-medium">Delete billing account</p>13 <p className="text-muted-foreground mt-1 text-xs">14 Removes 3 projects and every invoice from March 2024 onward. This cannot be undone.15 </p>16 </div>17 <Button18 variant="destructive"19 size="sm"20 className="self-start"21 leftIcon={<TrashIcon className="size-4" size={16} />}22 >23 Delete account24 </Button>25 </Stack>26 );27}
Outline
Invoices
142 records, Jan 1 to Mar 31
1import { Button } from '@/components/ui/button';2import { DownloadIcon } from '@/components/ui/download';3import { Flex } from '@/components/ui/flex';45export function Outline() {6 return (7 <Flex8 align="center"9 justify="between"10 gap="4"11 className="border-border bg-card w-full max-w-md rounded-xl border p-4"12 >13 <div className="min-w-0">14 <p className="text-sm font-medium">Invoices</p>15 <p className="text-muted-foreground text-xs">142 records, Jan 1 to Mar 31</p>16 </div>17 <Button variant="outline" leftIcon={<DownloadIcon className="size-4" size={16} />}>18 Export CSV19 </Button>20 </Flex>21 );22}
Ghost
q3-roadmap.md
Edited by Nadia 4h ago
1import { Button } from '@/components/ui/button';2import { MoreHorizontalIcon } from '@/components/ui/more-horizontal';3import { Flex } from '@/components/ui/flex';45export function Ghost() {6 return (7 <Flex8 align="center"9 justify="between"10 gap="2"11 className="border-border bg-card w-full max-w-md rounded-xl border p-2 pl-4"12 >13 <div className="min-w-0">14 <p className="truncate text-sm font-medium">q3-roadmap.md</p>15 <p className="text-muted-foreground text-xs">Edited by Nadia 4h ago</p>16 </div>17 <Flex align="center" gap="1">18 <Button variant="ghost" size="sm">19 Rename20 </Button>21 <Button variant="ghost" size="sm">22 Duplicate23 </Button>24 <Button variant="ghost" size="icon" aria-label="More actions">25 <MoreHorizontalIcon className="size-4" size={16} />26 </Button>27 </Flex>28 </Flex>29 );30}
Link
Rate limit reached
This workspace made 10,000 API requests in the last hour. Limits reset at 14:00 UTC.
1import { Button } from '@/components/ui/button';2import { ArrowUpRightIcon } from '@/components/ui/arrow-up-right';3import { Stack } from '@/components/ui/stack';45export function Link() {6 return (7 <Stack gap="2" className="border-border bg-card w-full max-w-sm rounded-xl border p-4">8 <p className="text-sm font-medium">Rate limit reached</p>9 <p className="text-muted-foreground text-xs">10 This workspace made 10,000 API requests in the last hour. Limits reset at 14:00 UTC.11 </p>12 <Button13 variant="link"14 className="self-start text-sm"15 rightIcon={<ArrowUpRightIcon className="size-3.5" size={14} />}16 >17 Read the rate limit docs18 </Button>19 </Stack>20 );21}
Referencia de API
Button
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
variant | "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "default" | Estilo del botón. |
size | "default" | "sm" | "lg" | "icon" | "default" | Tamaño del botón. |
loading | boolean | false | Muestra el loader e ignora los clicks. El botón sigue siendo enfocable y queda marcado con aria-disabled. |
loader | React.ReactNode | <Loader2 /> | Componente loader personalizado para mostrar durante la carga. |
leftIcon | React.ReactNode | — | Ícono que aparece a la izquierda del contenido. |
rightIcon | React.ReactNode | — | Ícono que aparece a la derecha del contenido. |
fullWidth | boolean | false | Hace que el botón ocupe todo el ancho disponible. |
disabled | boolean | — | Desactiva el botón. |
className | string | — | Clases adicionales. |
type | "button" | "submit" | "reset" | "button" | Tipo de botón. |
children | React.ReactNode | — | Contenido del botón (texto, íconos, etc.). |
ButtonGroup
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Dirección del grupo de botones. |
attached | boolean | false | Une los botones (sin bordes redondeados entre ellos). |
className | string | — | Clases adicionales. |
children | React.ReactNode | — | Botones dentro del grupo. |
¿Encontraste algo que mejorar?
¿Notaste un error, tipografía o detalle faltante en esta página? Ayúdanos a mejorar la documentación abriendo un issue en GitHub.