uniquel
componentbutton-groupsource: ok · hit
Button Group
Primary and secondary actions as one unit - depth on hover, underline reveal on the quiet CTA.
npx shadcn add "https://uniquel.io/r/button-group.json"free
Where it goes
cta
Interaction
scroll-reveal
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
Files1 · source cached 10/8/2026, 10:48:26 PM
components/conversion/ButtonGroup.tsx
'use client';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
//
// primary Main CTA — label, optional href / onClick / icon
// secondary Quiet companion action
// size sm | md | lg
// alignment left | center | right
// loading Disable and show a refined spinner on primary
// className Styles for the outer shell
//
type Action = {
label: string;
href?: string;
onClick?: () => void;
icon?: ReactNode;
};
type ButtonGroupProps = {
primary?: Action;
secondary?: Action;
size?: 'sm' | 'md' | 'lg';
alignment?: 'left' | 'center' | 'right';
loading?: boolean;
className?: string;
};
const SIZE = {
sm: 'px-4 py-2 text-xs',
md: 'px-5 py-2.5 text-sm',
lg: 'px-6 py-3 text-base',
};
const ALIGN = {
left: 'justify-start',
center: 'justify-center',
right: 'justify-end',
};
function ActionEl({
action,
className,
children,
disabled,
}: {
action: Action;
className: string;
children: ReactNode;
disabled?: boolean;
}) {
if (action.href && !disabled) {
return (
<a href={action.href} className={className} onClick={action.onClick}>
{children}
</a>
);
}
return (
<button
type='button'
className={className}
onClick={action.onClick}
disabled={disabled}
>
{children}
</button>
);
}
export default function ButtonGroup({
primary = { label: 'Start free', href: '#' },
secondary = { label: 'View demo', href: '#' },
size = 'md',
alignment = 'left',
loading = false,
className = '',
}: ButtonGroupProps) {
const pad = SIZE[size];
return (
<motion.div
className={[
'inline-flex flex-wrap items-center gap-3',
ALIGN[alignment],
className,
]
.filter(Boolean)
.join(' ')}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
>
<motion.div
whileHover={{ y: -1 }}
whileTap={{ scale: 0.985 }}
transition={{ type: 'spring', stiffness: 420, damping: 28 }}
>
<ActionEl
action={primary}
disabled={loading}
className={[
'relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-full bg-[var(--color-primary,#111827)] font-medium tracking-wide text-[color:var(--color-primary-foreground,#fff)] shadow-[0_1px_0_rgba(255,255,255,0.12)_inset,0_8px_24px_rgba(0,0,0,0.18)] transition-[background-color,box-shadow] duration-300 hover:opacity-90 hover:shadow-[0_1px_0_rgba(255,255,255,0.16)_inset,0_12px_32px_rgba(0,0,0,0.22)] focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[color:var(--color-ring,#111827)] focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-70',
pad,
].join(' ')}
>
{loading ? (
<span
className='h-3.5 w-3.5 animate-spin rounded-full border border-white/30 border-t-white'
aria-hidden
/>
) : (
primary.icon
)}
{primary.label}
</ActionEl>
</motion.div>
{secondary ? (
<ActionEl
action={secondary}
className={[
'group relative inline-flex items-center gap-1.5 font-medium tracking-wide text-[color:var(--color-muted-foreground,#4b5563)] transition-colors hover:text-[color:var(--color-foreground,#111827)] focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[color:var(--color-ring,#111827)] focus-visible:ring-offset-2',
pad.replace(/px-\d+(\.\d+)?/, 'px-1'),
].join(' ')}
>
{secondary.icon}
<span className='relative'>
{secondary.label}
<span className='absolute -bottom-0.5 left-0 h-px w-full origin-left scale-x-0 bg-[var(--color-foreground,#111827)] transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-x-100' />
</span>
</ActionEl>
) : null}
</motion.div>
);
}