Button
This documentation covers four types of buttons: the base Button, Button With Spinner, Button With Icon, and Button Group.
Button Variant
- Basic Button
- Button With Spinner
- Button With Icon
- Button Group
Buttons allow users to take actions with a single tap. They can be customized with different styles, sizes, and animations.
- Preview
- Code
<Button
variant="default"
size="md"
>
Click me
</Button>
Installation
- CLI
- manual
ignix add component button
'use client';
import { motion } from 'framer-motion';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../../utils/cn';
export interface ButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onDrag'>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
animationVariant?: string;
children?: React.ReactNode;
}
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'px-4 py-2 bg-primary text-white hover:bg-primary/90',
primary: 'px-4 py-2 bg-secondary text-secondary-foreground hover:bg-secondary/90',
secondary: 'bg-muted text-muted-foreground hover:bg-muted/90',
success: 'bg-success text-success-foreground hover:bg-success/90',
warning: 'bg-warning text-warning-foreground hover:bg-warning/90',
danger: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
subtle: 'bg-accent text-accent-foreground hover:bg-accent/80',
elevated: 'bg-background shadow-md hover:shadow-lg',
glass: 'bg-black/10 backdrop-blur-lg text-white hover:bg-black/20',
neon: 'bg-pink-500 text-white shadow-lg shadow-pink-500/50 hover:bg-pink-600',
pill: 'rounded-full px-6 py-2 bg-pill text-white hover:bg-pill/90',
none: '',
},
size: {
xs: 'h-8 px-2 text-xs rounded-sm',
sm: 'h-9 px-3 text-sm rounded-md',
md: 'h-10 px-4 text-base rounded-md',
lg: 'h-12 px-6 text-lg rounded-lg',
xl: 'h-14 px-8 text-xl rounded-lg',
icon: 'h-10 w-10 p-2',
pill: 'h-10 px-6 text-base rounded-full',
block: 'w-full py-3 text-lg',
compact: 'h-8 px-2 text-xs',
wide: 'px-12 py-3 text-lg',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
);
const animations = {
bounce: {
animate: { y: [0, -10, 0] },
transition: { repeat: Infinity, duration: 0.5 },
},
bounceSlow: {
animate: { y: [0, -15, 0] },
transition: { repeat: Infinity, duration: 0.8 },
},
bounceFast: {
animate: { y: [0, -8, 0] },
transition: { repeat: Infinity, duration: 0.3 },
},
bounceSmooth: {
animate: { y: [0, -20, 0] },
transition: { repeat: Infinity, duration: 1, ease: 'easeInOut' },
},
bounceJelly: {
animate: { scale: [1, 1.2, 0.9, 1.1, 1] },
transition: { repeat: Infinity, duration: 1, ease: 'easeInOut' },
},
rotateClockwiseSlow: {
whileHover: { rotate: 360 },
transition: { duration: 0.3 },
},
rotateClockwiseFast: {
whileHover: { rotate: 360 },
transition: { duration: 0.5 },
},
rotateAntiClockwiseSlow: {
whileHover: { rotate: -360 },
transition: { duration: 0.3 },
},
rotateAntiClockwiseFast: {
whileHover: { rotate: -360 },
transition: { duration: 0.5 },
},
rotatePingPong: {
whileHover: { rotate: [0, 15, -15, 0] },
transition: { repeat: Infinity, duration: 1, ease: 'easeInOut' },
},
scaleUp: { whileHover: { scale: 1.2 }, transition: { duration: 0.3 } },
scaleDown: { whileHover: { scale: 0.9 }, transition: { duration: 0.3 } },
scalePulse: {
animate: { scale: [1, 1.1, 1] },
transition: { repeat: Infinity, duration: 0.6 },
},
scaleExpandContract: {
animate: { scale: [1, 1.3, 1] },
transition: { repeat: Infinity, duration: 1.2, ease: 'easeInOut' },
},
scaleHeartbeat: {
animate: { scale: [1, 1.15, 1] },
transition: { repeat: Infinity, duration: 0.8, ease: 'easeInOut' },
},
flipX: { whileHover: { rotateY: 360 }, transition: { duration: 0.5 } },
flipY: { whileHover: { rotateX: 360 }, transition: { duration: 0.5 } },
flipCard: {
animate: { rotateY: [0, 180, 360] },
transition: { repeat: Infinity, duration: 2, ease: 'easeInOut' },
},
fadeBlink: {
animate: { opacity: [1, 0, 1] },
transition: { repeat: Infinity, duration: 0.5 },
},
fadeInOut: {
animate: { opacity: [0, 1, 0] },
transition: { repeat: Infinity, duration: 2 },
},
fadeInOutTop: {
animate: { y: [-20, 0, -20], opacity: [0, 1, 0] },
transition: { repeat: Infinity, duration: 2 },
},
fadeInOutBottom: {
animate: { y: [20, 0, 20], opacity: [0, 1, 0] },
transition: { repeat: Infinity, duration: 2 },
},
fadeInOutLeft: {
animate: { x: [-20, 0, -20], opacity: [0, 1, 0] },
transition: { repeat: Infinity, duration: 2 },
},
fadeInOutRight: {
animate: { x: [20, 0, 20], opacity: [0, 1, 0] },
transition: { repeat: Infinity, duration: 2 },
},
fadeInOutTopLeft: {
animate: { x: [-20, 0, -20], y: [-20, 0, -20], opacity: [0, 1, 0] },
transition: { repeat: Infinity, duration: 2 },
},
fadeInOutTopRight: {
animate: { x: [20, 0, 20], y: [-20, 0, -20], opacity: [0, 1, 0] },
transition: { repeat: Infinity, duration: 2 },
},
fadeInOutBottomLeft: {
animate: { x: [-20, 0, -20], y: [20, 0, 20], opacity: [0, 1, 0] },
transition: { repeat: Infinity, duration: 2 },
},
fadeInOutBottomRight: {
animate: { x: [20, 0, 20], y: [20, 0, 20], opacity: [0, 1, 0] },
transition: { repeat: Infinity, duration: 2 },
},
swipeRight: { whileHover: { x: 20 }, transition: { duration: 0.3 } },
swipeLeft: { whileHover: { x: -20 }, transition: { duration: 0.3 } },
swipeUp: { whileHover: { y: 20 }, transition: { duration: 0.3 } },
swipeDown: { whileHover: { y: -20 }, transition: { duration: 0.3 } },
slideLeftRight: {
animate: { x: [0, -20, 20, 0] },
transition: { repeat: Infinity, duration: 2, ease: 'easeInOut' },
},
slideUpDown: {
animate: { y: [0, -20, 20, 0] },
transition: { repeat: Infinity, duration: 2, ease: 'easeInOut' },
},
press3D: {
whileTap: { scale: 0.9, y: 5 },
transition: { duration: 0.1 },
},
press3DSoft: {
whileTap: { scale: 0.95, y: 3 },
transition: { duration: 0.1 },
},
press3DHard: {
whileTap: { scale: 0.85, y: 8 },
transition: { duration: 0.1 },
},
press3DPop: {
whileTap: { scale: 1.05, y: -2 },
transition: { duration: 0.1 },
},
press3DDepth: {
whileTap: {
scale: 0.9,
y: 5,
boxShadow: 'inset 4px 4px 10px rgba(0,0,0,0.2)',
},
transition: { duration: 0.1 },
},
spinSlow: {
animate: { rotate: [0, 360] },
transition: { repeat: Infinity, duration: 5, ease: 'linear' },
},
spinFast: {
animate: { rotate: [0, 360] },
transition: { repeat: Infinity, duration: 1, ease: 'linear' },
},
spinPingPong: {
animate: { rotate: [0, 90, -90, 0] },
transition: { repeat: Infinity, duration: 2, ease: 'easeInOut' },
},
shake: {
animate: { x: [0, -5, 5, -5, 5, 0] },
transition: { repeat: Infinity, duration: 0.5 },
},
wobble: {
animate: { rotate: [0, -5, 5, -5, 5, 0] },
transition: { repeat: Infinity, duration: 0.8 },
},
tilt3D: {
animate: { rotateX: 15, rotateY: 15 },
transition: { repeat: Infinity, duration: 1, ease: 'easeInOut' },
},
tiltCrazy: {
animate: {
rotateX: [0, 15, -15, 10, -10, 0],
rotateY: [0, -10, 10, -15, 15, 0],
},
transition: { repeat: Infinity, duration: 3, ease: 'easeInOut' },
},
pulse: {
animate: { scale: [1, 1.1, 1] },
transition: { repeat: Infinity, duration: 0.6 },
},
gradientShift: {
animate: {
background: [
'linear-gradient(45deg, #ff0000, #00ff00)',
'linear-gradient(45deg, #00ff00, #0000ff)',
'linear-gradient(45deg, #0000ff, #ff0000)',
],
},
transition: { repeat: Infinity, duration: 3, ease: 'linear' },
},
borderPulse: {
whileHover: {
border: '2px solid #ff0000',
boxShadow: '0 0 10px #ff0000',
},
transition: { repeat: Infinity, duration: 1, ease: 'easeInOut' },
},
borderPulseRainbow: {
animate: {
boxShadow: ['0 0 10px #ff0000', '0 0 10px #0000ff', '0 0 10px #00ff00', '0 0 10px #ff00ff'],
},
transition: { repeat: Infinity, duration: 2, ease: 'easeInOut' },
},
shadowGlow: {
whileHover: {
boxShadow: '0 0 20px rgba(37, 99, 255, 0.8)',
},
transition: { duration: 0.5 },
},
neonGlow: {
whileHover: {
boxShadow: '0 0 10px #00ff00, 0 0 20px #00ff00, 0 0 40px #00ff00',
},
transition: { duration: 0.5 },
},
rippleBorder: {
whileHover: {
scale: 1.1,
boxShadow: '0 0 0 10px rgba(0, 255, 0, 0.2)',
},
transition: { duration: 0.5 },
},
ping: {
animate: { scale: [1, 1.3], opacity: [1, 0] },
transition: { duration: 0.8, repeat: Infinity },
},
reveal: {
initial: { scale: 0 },
animate: { rotate: 360, scale: 1 },
transition: {
type: 'spring',
stiffness: 260,
damping: 20,
duration: 0.8,
repeat: Infinity,
},
},
nina: {},
};
// nina
const ninaTextVariants = {
initial: { opacity: 0, y: -10 },
hover: (i: number) => ({
opacity: 1,
y: 0,
transition: { delay: i * 0.045, duration: 0.3, ease: [0.75, 0, 0.125, 1] },
}),
};
const ninaBeforeVariants = {
initial: { opacity: 1, y: 0 },
hover: {
opacity: 0,
y: 20,
transition: { duration: 0.3, ease: [0.75, 0, 0.125, 1] },
},
};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, animationVariant, children, ...props }, ref) => {
const animationProps = animationVariant ? animations[animationVariant] || {} : {};
const slotProps = asChild ? { ...props } : {};
const motionProps = !asChild ? { ...props, ...animationProps } : {};
const renderNinaVariant = () => {
const content = typeof children === 'string' ? (
<>
<motion.span
className="absolute inset-0 flex items-center justify-center"
variants={ninaBeforeVariants}
>
{children}
</motion.span>
<motion.div className="relative flex gap-1" initial="initial" whileHover="hover">
{children.split('').map((char, i) => (
<motion.span key={i} custom={i} variants={ninaTextVariants}>
{char}
</motion.span>
))}
</motion.div>
</>
) : (
<span>{children}</span>
);
return (
<motion.button
className={cn(buttonVariants({ variant, size }), className, 'relative overflow-hidden')}
ref={ref}
{...(motionProps as any)}
initial="initial"
whileHover="hover"
>
{content}
</motion.button>
);
};
if (asChild) {
return (
<Slot className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...slotProps}>
{children}
</Slot>
);
}
if (animationVariant === 'nina') {
return renderNinaVariant();
}
return (
<motion.button
className={cn(buttonVariants({ variant, size }), className)}
ref={ref}
{...(motionProps as any)}
>
{children}
</motion.button>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
Usage
Import the component:
import { Button } from './components/ui';
Basic Usage
function BasicButton() {
return (
<Button>Click me</Button>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'outline' | 'ghost' | 'link' | 'subtle' | 'elevated' | 'glass' | 'neon' | 'pill' | 'none' | 'default' | The visual style variant of the button |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'icon' | 'pill' | 'block' | 'compact' | 'wide' | 'md' | The size of the button |
asChild | boolean | false | When true, merges props with the child component instead of rendering a button element |
animationVariant | string | undefined | Animation variant to apply to the button (e.g., 'bounce', 'pulse', 'spin', etc.) |
children | React.ReactNode | - | The button content/text |
className | string | - | Additional CSS classes to apply |
disabled | boolean | false | Whether the button is disabled |
onClick | (event: React.MouseEvent<HTMLButtonElement>) => void | - | Click event handler |
| All standard button HTML attributes | - | - | All standard HTML button attributes are supported |
The ButtonWithSpinner component is a specialized button that displays a loading spinner and changes its text during loading states. It automatically disables the button during loading and intelligently adjusts spinner colors based on the button variant for optimal visibility.
- Preview
- Code
<ButtonWithSpinner
isLoading={false}
loadingText="Loading..."
spinnerVariant="default"
spinnerSize={16}
variant="default"
size="md"
onClick={handleClick}
>
Click Me
</ButtonWithSpinner>
Installation
- CLI
- MANUAL
ignix add component ButtonWithSpinner
'use client';
import * as React from 'react';
import { Button, type ButtonProps } from '../button';
import { Spinner } from '../spinner';
import { cn } from '../../../utils/cn';
export interface ButtonWithSpinnerProps extends Omit<ButtonProps, 'disabled'> {
isLoading?: boolean;
loadingText?: string;
spinnerSize?: number;
spinnerVariant?: 'default' | 'bars' | 'dots-bounce';
spinnerColor?: string;
children: React.ReactNode;
}
export const ButtonWithSpinner = React.forwardRef<
HTMLButtonElement,
ButtonWithSpinnerProps
>(
(
{
isLoading = false,
loadingText = 'Loading...',
spinnerSize = 16,
spinnerVariant = 'default',
spinnerColor,
children,
className,
variant,
...props
},
ref
) => {
const getSpinnerColor = (): string => {
if (spinnerColor) return spinnerColor;
const darkBackgroundVariants = ['default', 'primary', 'secondary', 'success', 'warning', 'danger', 'pill', 'neon'];
const lightBackgroundVariants = ['outline', 'ghost', 'subtle', 'elevated', 'glass'];
if (variant && darkBackgroundVariants.includes(variant)) {
if (spinnerVariant === 'bars' || spinnerVariant === 'dots-bounce') {
return 'bg-white';
}
return 'border-white';
} else if (variant && lightBackgroundVariants.includes(variant)) {
if (spinnerVariant === 'bars' || spinnerVariant === 'dots-bounce') {
return 'bg-primary';
}
return 'border-primary';
}
if (spinnerVariant === 'bars' || spinnerVariant === 'dots-bounce') {
return 'bg-white';
}
return 'border-white';
};
return (
<Button
ref={ref}
disabled={isLoading}
className={cn('relative', className)}
variant={variant}
{...props}
>
{isLoading && (
<Spinner
size={spinnerSize}
variant={spinnerVariant}
color={getSpinnerColor()}
className="mr-2"
/>
)}
<span>{isLoading ? loadingText : children}</span>
</Button>
);
}
);
ButtonWithSpinner.displayName = 'ButtonWithSpinner';
Usage
Import the component:
import { ButtonWithSpinner } from '@site/src/components/UI/button-with-spinner';
Basic Usage
function BasicButtonWithSpinner() {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
setIsLoading(true);
try {
await someAsyncOperation();
} finally {
setIsLoading(false);
}
};
return (
<ButtonWithSpinner
isLoading={isLoading}
loadingText="Loading..."
onClick={handleClick}
>
Submit
</ButtonWithSpinner>
);
}
With Custom Loading Text
function SaveButton() {
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
await saveData();
} finally {
setIsSaving(false);
}
};
return (
<ButtonWithSpinner
isLoading={isSaving}
loadingText="Saving..."
onClick={handleSave}
>
Save Changes
</ButtonWithSpinner>
);
}
With Different Spinner Variants
function CustomSpinnerButton() {
const [isLoading, setIsLoading] = useState(false);
return (
<div className="flex gap-4">
<ButtonWithSpinner
isLoading={isLoading}
spinnerVariant="default"
onClick={() => setIsLoading(!isLoading)}
>
Default Spinner
</ButtonWithSpinner>
<ButtonWithSpinner
isLoading={isLoading}
spinnerVariant="bars"
onClick={() => setIsLoading(!isLoading)}
>
Bars Spinner
</ButtonWithSpinner>
<ButtonWithSpinner
isLoading={isLoading}
spinnerVariant="dots-bounce"
onClick={() => setIsLoading(!isLoading)}
>
Dots Bounce
</ButtonWithSpinner>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
isLoading | boolean | false | Whether the button is in a loading state. When true, the button is disabled and shows a spinner. |
loadingText | string | 'Loading...' | Text to display when the button is in loading state. |
spinnerSize | number | 16 | Size of the spinner in pixels. Should be adjusted based on button size. |
spinnerVariant | 'default' | 'bars' | 'dots-bounce' | 'default' | Variant of the spinner animation. |
spinnerColor | string | - | Tailwind CSS class for spinner color (e.g., "bg-white", "border-white"). Auto-determined based on button variant if not provided. |
variant | 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'outline' | 'ghost' | 'subtle' | 'elevated' | 'glass' | 'neon' | 'pill' | 'default' | The visual style of the button. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The size of the button. |
children | React.ReactNode | - | The button text/content to display when not loading. |
Example of Form Submission
function FormExample() {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await submitForm();
// Show success message
} catch (error) {
// Handle error
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<ButtonWithSpinner
type="submit"
isLoading={isSubmitting}
loadingText="Submitting..."
>
Submit Form
</ButtonWithSpinner>
</form>
);
}
Example of API Call
function ApiCallExample() {
const [isLoading, setIsLoading] = useState(false);
const handleApiCall = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/data');
const data = await response.json();
// Handle data
} finally {
setIsLoading(false);
}
};
return (
<ButtonWithSpinner
isLoading={isLoading}
loadingText="Loading data..."
onClick={handleApiCall}
>
Load Data
</ButtonWithSpinner>
);
}
The ButtonWithIcon component extends the base Button component with comprehensive icon support. It allows you to position icons on the left or right of text, create icon-only buttons, and display loading spinners. This component is perfect for creating intuitive and visually appealing user interfaces.
- Preview
- Code
<ButtonWithIcon
variant="default"
size="md"
iconPosition="left"
icon={<Download />}
>
Button Text
</ButtonWithIcon>
Installation
- CLI
- manual
ignix add component ButtonWithIcon
/**
* ButtonWithIcon Component
*
* An enhanced button component that extends the base Button with icon support.
* Supports multiple icon positions, icon-only buttons, and loading states.
*/
'use client';
import * as React from 'react';
import { Button, type ButtonProps } from '../button';
import { Spinner } from '../spinner';
import { cn } from '../../../utils/cn';
export interface ButtonWithIconProps extends ButtonProps {
/**
* Icon component to display. Can be from lucide-react or any React component.
*/
icon?: React.ReactNode;
/**
* Position of the icon relative to the text.
* - 'left': Icon appears before the text (default)
* - 'right': Icon appears after the text
* - 'only': Only the icon is displayed (no text)
*/
iconPosition?: 'left' | 'right' | 'only';
/**
* Show loading spinner instead of icon or text.
* When true, the button is automatically disabled.
*/
loading?: boolean;
/**
* Size of the icon in pixels. Defaults to 16.
*/
iconSize?: number;
}
export const ButtonWithIcon = React.forwardRef<HTMLButtonElement, ButtonWithIconProps>(
(
{
icon,
iconPosition = 'left',
loading = false,
iconSize = 16,
children,
className,
disabled,
size = 'md',
...props
},
ref
) => {
const isIconOnly = iconPosition === 'only' || (!children && icon);
const isDisabled = disabled || loading;
// Loading state rendering
if (loading) {
const spinnerSize = size === 'xs' ? 12 : size === 'sm' ? 14 : size === 'lg' ? 18 : size === 'xl' ? 20 : 16;
if (isIconOnly) {
return (
<Button
ref={ref}
size={size}
disabled={isDisabled}
className={cn('p-0', className)}
{...props}
>
<Spinner size={spinnerSize} variant="default" />
</Button>
);
}
return (
<Button
ref={ref}
size={size}
disabled={isDisabled}
className={className}
{...props}
>
<Spinner size={spinnerSize} variant="default" />
{children && <span className="ml-2">{children}</span>}
</Button>
);
}
// Icon-only button rendering
if (isIconOnly && icon) {
const iconButtonSize = size === 'icon' || iconPosition === 'only' ? 'icon' : size;
return (
<Button
ref={ref}
size={iconButtonSize}
disabled={isDisabled}
className={className}
{...props}
>
<span
style={{
width: iconSize,
height: iconSize,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{icon}
</span>
</Button>
);
}
// Button with icon and text rendering
const iconElement = icon && (
<span
style={{
width: iconSize,
height: iconSize,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
className="flex-shrink-0"
>
{icon}
</span>
);
return (
<Button
ref={ref}
size={size}
disabled={isDisabled}
className={className}
{...props}
>
{iconPosition === 'left' && iconElement}
{children && <span>{children}</span>}
{iconPosition === 'right' && iconElement}
</Button>
);
}
);
ButtonWithIcon.displayName = 'ButtonWithIcon';
Usage
Import the component:
import { ButtonWithIcon } from './components/ui';
Basic Usage
import { ButtonWithIcon } from './components/ui';
import { Download } from 'lucide-react';
function BasicButtonWithIcon() {
return (
<ButtonWithIcon icon={<Download />}>
Download
</ButtonWithIcon>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
icon | React.ReactNode | undefined | Icon component to display (from lucide-react or any React component) |
iconPosition | 'left' | 'right' | 'only' | 'left' | Position of the icon relative to text |
loading | boolean | false | Show loading spinner and disable button |
iconSize | number | 16 | Size of the icon in pixels |
variant | string | 'default' | Visual variant of the button (inherited from Button) |
size | string | 'md' | Size of the button (inherited from Button) |
disabled | boolean | false | Disable the button |
children | React.ReactNode | undefined | Button text content |
All other props from the base Button component are also supported.
Examples
Icon Left (Default)
The icon appears before the text. This is the most common pattern for buttons with icons.
<ButtonWithIcon icon={<Download />} iconPosition="left">
Download
</ButtonWithIcon>
Icon Right
The icon appears after the text. Useful for actions like "Next" or "Continue" where the icon indicates direction.
<ButtonWithIcon icon={<Send />} iconPosition="right">
Send
</ButtonWithIcon>
Icon Only
Icon-only buttons are compact and space-efficient. Perfect for toolbars, action menus, or when space is limited.
<ButtonWithIcon icon={<Settings />} iconPosition="only" />
Loading State
The loading state automatically disables the button and shows a spinner. Useful for async operations like form submissions or API calls.
<ButtonWithIcon loading={true}>
Processing
</ButtonWithIcon>
Form Submission with Loading
import { useState } from 'react';
import { ButtonWithIcon } from './components/ui';
import { Save } from 'lucide-react';
function FormExample() {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
setIsSubmitting(true);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
setIsSubmitting(false);
};
return (
<ButtonWithIcon
icon={<Save />}
loading={isSubmitting}
onClick={handleSubmit}
variant="primary"
>
Save Changes
</ButtonWithIcon>
);
}
The ButtonGroup component groups multiple buttons together with consistent spacing, active state highlighting, and responsive wrapping capabilities. It's perfect for creating filter buttons, toggle groups, size selectors, and other scenarios where you need related buttons grouped together.
- Preview
- Code
<ButtonGroup
items={[
{ value: 'option1', children: 'Option 1' },
{ value: 'option2', children: 'Option 2' },
{ value: 'option3', children: 'Option 3' }
]}
activeValue="option1"
onChange={(value) => setActiveValue(value)}
orientation="horizontal"
spacing="gap-2"
wrap={true}
/>
Installation
- CLI
- manual
ignix add component ButtonGroup
/**
* ButtonGroup Component
*
* A component that groups multiple buttons together with consistent spacing,
* active state highlighting, and responsive wrapping capabilities.
*/
'use client';
import * as React from 'react';
import { Button, type ButtonProps } from '../button';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../../utils/cn';
const buttonGroupVariants = cva(
'inline-flex items-center',
{
variants: {
orientation: {
horizontal: 'flex-row',
vertical: 'flex-col',
},
wrap: {
true: 'flex-wrap',
false: 'flex-nowrap',
},
},
defaultVariants: {
orientation: 'horizontal',
wrap: true,
},
}
);
export interface ButtonGroupItem extends Omit<ButtonProps, 'onClick'> {
value: string;
onClick?: (value: string, event: React.MouseEvent<HTMLButtonElement>) => void;
}
export interface ButtonGroupProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'>,
VariantProps<typeof buttonGroupVariants> {
items: ButtonGroupItem[];
activeValue?: string;
defaultValue?: string;
onChange?: (value: string) => void;
spacing?: string;
activeVariant?: ButtonProps['variant'];
multiple?: boolean;
activeValues?: string[];
}
export const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
(
{
items,
activeValue: controlledActiveValue,
defaultValue,
onChange,
wrap = true,
spacing = 'gap-2',
activeVariant,
multiple = false,
activeValues: controlledActiveValues,
orientation = 'horizontal',
className,
...props
},
ref
) => {
const [internalActiveValue, setInternalActiveValue] = React.useState<string | undefined>(
defaultValue
);
const [internalActiveValues, setInternalActiveValues] = React.useState<string[]>(
controlledActiveValues || (defaultValue ? [defaultValue] : [])
);
const isControlled = controlledActiveValue !== undefined || controlledActiveValues !== undefined;
const activeValue = isControlled && !multiple ? controlledActiveValue : internalActiveValue;
const activeValues = isControlled && multiple
? controlledActiveValues || []
: internalActiveValues;
const handleButtonClick = (
itemValue: string,
itemOnClick?: (value: string, event: React.MouseEvent<HTMLButtonElement>) => void
) => {
return (event: React.MouseEvent<HTMLButtonElement>) => {
itemOnClick?.(itemValue, event);
if (multiple) {
const newActiveValues = activeValues.includes(itemValue)
? activeValues.filter(v => v !== itemValue)
: [...activeValues, itemValue];
if (!isControlled) {
setInternalActiveValues(newActiveValues);
}
onChange?.(itemValue);
} else {
if (!isControlled) {
setInternalActiveValue(itemValue);
}
onChange?.(itemValue);
}
};
};
const isButtonActive = (itemValue: string): boolean => {
if (multiple) {
return activeValues.includes(itemValue);
}
return activeValue === itemValue;
};
return (
<div
ref={ref}
className={cn(
buttonGroupVariants({ orientation, wrap }),
spacing,
className
)}
role="group"
aria-label="Button group"
{...props}
>
{items.map((item) => {
const { value, onClick: itemOnClick, variant, className: itemClassName, ...itemProps } = item;
const isActive = isButtonActive(value);
const buttonVariant = isActive && activeVariant
? activeVariant
: variant || 'default';
return (
<Button
key={value}
variant={buttonVariant}
className={cn(
isActive && !activeVariant && 'ring-2 ring-offset-2 ring-primary',
itemClassName
)}
onClick={handleButtonClick(value, itemOnClick)}
aria-pressed={isActive}
{...itemProps}
>
{item.children}
</Button>
);
})}
</div>
);
}
);
ButtonGroup.displayName = 'ButtonGroup';
Usage
Import the component:
import { ButtonGroup } from './components/ui';
Basic Usage
import { ButtonGroup } from './components/ui';
import { useState } from 'react';
function BasicButtonGroup() {
const [activeValue, setActiveValue] = useState('save');
return (
<ButtonGroup
items={[
{ value: 'save', children: 'Save' },
{ value: 'cancel', children: 'Cancel' },
{ value: 'delete', children: 'Delete' }
]}
activeValue={activeValue}
onChange={(value) => setActiveValue(value)}
/>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | ButtonGroupItem[] | required | Array of button items to display in the group |
activeValue | string | undefined | Currently active button value (controlled mode) |
defaultValue | string | undefined | Default active button value (uncontrolled mode) |
onChange | (value: string) => void | undefined | Callback fired when a button is clicked |
wrap | boolean | true | Whether buttons should wrap to multiple lines |
spacing | string | 'gap-2' | Spacing between buttons (Tailwind spacing class) |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Orientation of the button group |
activeVariant | ButtonProps['variant'] | undefined | Variant to apply to active buttons |
multiple | boolean | false | Allow multiple buttons to be active simultaneously |
activeValues | string[] | undefined | Array of active values when multiple selection is enabled |
ButtonGroupItem
| Prop | Type | Description |
|---|---|---|
value | string | required - Unique identifier for the button |
children | React.ReactNode | Button text content |
variant | string | Visual variant of the button |
size | string | Size of the button |
onClick | (value: string, event: MouseEvent) => void | Click handler for the button |
All other ButtonProps | - | All other props from the base Button component |
Examples
Controlled Mode
Use activeValue and onChange to control the active state from a parent component.
import { useState } from 'react';
import { ButtonGroup } from './components/ui';
function ControlledButtonGroup() {
const [activeValue, setActiveValue] = useState('all');
return (
<ButtonGroup
items={[
{ value: 'all', children: 'All' },
{ value: 'active', children: 'Active' },
{ value: 'inactive', children: 'Inactive' }
]}
activeValue={activeValue}
onChange={(value) => {
setActiveValue(value);
console.log(`Filter changed to: ${value}`);
}}
/>
);
}
Uncontrolled Mode
Use defaultValue to set an initial active value without controlling it.
import { ButtonGroup } from './components/ui';
function UncontrolledButtonGroup() {
return (
<ButtonGroup
items={[
{ value: 'option1', children: 'Option 1' },
{ value: 'option2', children: 'Option 2' },
{ value: 'option3', children: 'Option 3' }
]}
defaultValue="option1"
/>
);
}
Multiple Selection
Enable multiple selection by setting multiple={true} and using activeValues.
import { useState } from 'react';
import { ButtonGroup } from './components/ui';
function MultipleSelectionButtonGroup() {
const [activeValues, setActiveValues] = useState<string[]>(['bold']);
return (
<ButtonGroup
items={[
{ value: 'bold', children: 'Bold' },
{ value: 'italic', children: 'Italic' },
{ value: 'underline', children: 'Underline' }
]}
multiple
activeValues={activeValues}
onChange={(value) => {
setActiveValues(prev =>
prev.includes(value)
? prev.filter(v => v !== value)
: [...prev, value]
);
}}
/>
);
}
Filter Buttons
Perfect for filtering content by status or category.
import { useState } from 'react';
import { ButtonGroup } from './components/ui';
function FilterButtons() {
const [filter, setFilter] = useState('all');
return (
<div>
<p className="text-sm font-medium mb-2">Filter by status:</p>
<ButtonGroup
items={[
{ value: 'all', children: 'All Items' },
{ value: 'published', children: 'Published' },
{ value: 'draft', children: 'Draft' },
{ value: 'archived', children: 'Archived' }
]}
activeValue={filter}
onChange={(value) => setFilter(value)}
/>
</div>
);
}
Size Selector
Use ButtonGroup for selecting sizes or other options.
import { useState } from 'react';
import { ButtonGroup } from './components/ui';
function SizeSelector() {
const [size, setSize] = useState('md');
return (
<div>
<p className="text-sm font-medium mb-2">Select size:</p>
<ButtonGroup
items={[
{ value: 'xs', children: 'XS' },
{ value: 'sm', children: 'SM' },
{ value: 'md', children: 'MD' },
{ value: 'lg', children: 'LG' },
{ value: 'xl', children: 'XL' }
]}
activeValue={size}
onChange={(value) => setSize(value)}
/>
</div>
);
}
Vertical Orientation
Display buttons vertically instead of horizontally.
import { ButtonGroup } from './components/ui';
function VerticalButtonGroup() {
return (
<ButtonGroup
items={[
{ value: 'top', children: 'Top' },
{ value: 'middle', children: 'Middle' },
{ value: 'bottom', children: 'Bottom' }
]}
orientation="vertical"
defaultValue="middle"
/>
);
}
Custom Spacing
Adjust spacing between buttons using Tailwind spacing classes.
import { ButtonGroup } from './components/ui';
function CustomSpacingButtonGroup() {
return (
<>
<ButtonGroup
items={[
{ value: '1', children: 'Tight' },
{ value: '2', children: 'Spacing' }
]}
spacing="gap-1"
/>
<ButtonGroup
items={[
{ value: '1', children: 'Wide' },
{ value: '2', children: 'Spacing' }
]}
spacing="gap-4"
/>
</>
);
}
Custom Active Variant
Apply a specific variant to active buttons.
import { useState } from 'react';
import { ButtonGroup } from './components/ui';
function CustomActiveVariantButtonGroup() {
const [activeValue, setActiveValue] = useState('option1');
return (
<ButtonGroup
items={[
{ value: 'option1', children: 'Option 1', variant: 'outline' },
{ value: 'option2', children: 'Option 2', variant: 'outline' },
{ value: 'option3', children: 'Option 3', variant: 'outline' }
]}
activeValue={activeValue}
onChange={setActiveValue}
activeVariant="success"
/>
);
}
Responsive Wrapping
Buttons automatically wrap to multiple lines on smaller screens when wrap={true}.
import { ButtonGroup } from './components/ui';
function ResponsiveButtonGroup() {
return (
<ButtonGroup
items={[
{ value: '1', children: 'Button 1' },
{ value: '2', children: 'Button 2' },
{ value: '3', children: 'Button 3' },
{ value: '4', children: 'Button 4' },
{ value: '5', children: 'Button 5' },
{ value: '6', children: 'Button 6' }
]}
wrap={true}
spacing="gap-2"
/>
);
}