shadimage
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 · medium
animationframer-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>
  );
}

More from uniquel

Browse all components in this library →