Buttons

Micro Expander

A micro-interaction button that expands from a circular icon to a pill shape containing text upon hover.

Interactive Toolbar

A real-world pattern showing how multiple expanders can be composed into a compact, high-density toolbar. This layout pattern is ideal for social feeds or content editors where screen real estate is premium, expanding controls only when the user intends to interact.

Visual Variants

Demonstrates the component's adaptability across standard visual styles. Whether used for primary actions, secondary details, or destructive operations, the expander maintains its smooth hover physics while adhering to the design system's color palette.

Async Loading & Success

Shows the button's built-in state management for asynchronous operations. When isLoading is active, the button gracefully collapses back to a circle to display a spinner, preventing further interaction until the process completes.

Click to simulate a save operation

Installation

npx shadcn@latest add https://satisui.xyz/r/micro-expander.json

Manual

  1. Install the required dependencies:
npm install motion
# or
yarn add motion
# or
pnpm add motion
  1. Copy and paste the following code into components/ui/micro-expander.tsx:
'use client';

import * as React from 'react';
import {
  motion,
  type HTMLMotionProps,
  type Variants,
  AnimatePresence,
} from 'motion/react';
import { Plus, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';

/**
 * Props for the MicroExpander component.
 */
interface MicroExpanderProps
  extends Omit<HTMLMotionProps<'button'>, 'children'> {
  /** The label text to display when the button is hovered/expanded. */
  text: string;
  /** An optional custom icon. Defaults to a Plus icon if not provided. */
  icon?: React.ReactNode;
  /** The visual style variant of the button. */
  variant?: 'default' | 'outline' | 'ghost' | 'destructive';
  /** If true, displays a spinner, disables interaction, and collapses the button. */
  isLoading?: boolean;
}

/**
 * A micro-interaction button that expands from a circular icon to a pill shape
 * containing text upon hover. It handles loading states by reverting to the
 * circular shape and displaying a spinner.
 */
const MicroExpander = React.forwardRef<HTMLButtonElement, MicroExpanderProps>(
  (
    {
      text,
      icon,
      variant = 'default',
      isLoading = false,
      className,
      onClick,
      ...props
    },
    ref
  ) => {
    const [isHovered, setIsHovered] = React.useState(false);

    const containerVariants: Variants = {
      initial: { width: '48px' },
      hover: { width: 'auto' },
      loading: { width: '48px' },
    };

    const textVariants: Variants = {
      initial: { opacity: 0, x: -10 },
      hover: {
        opacity: 1,
        x: 0,
        transition: { delay: 0.15, duration: 0.3, ease: 'easeOut' },
      },
      exit: {
        opacity: 0,
        x: -5,
        transition: { duration: 0.1, ease: 'linear' },
      },
    };

    const variantStyles = {
      default: 'bg-primary text-primary-foreground border border-primary',
      outline:
        'bg-transparent border border-input text-foreground hover:border-primary',
      ghost:
        'bg-accent/50 border border-transparent text-accent-foreground hover:bg-accent',
      destructive:
        'bg-destructive text-destructive-foreground border border-destructive hover:bg-destructive/90',
    };

    const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
      if (isLoading) return;
      onClick?.(e);
    };

    return (
      <motion.button
        ref={ref}
        className={cn(
          'relative flex h-12 items-center overflow-hidden rounded-full',
          'whitespace-nowrap font-medium text-sm uppercase tracking-wide',
          'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
          isLoading && 'cursor-not-allowed',
          variantStyles[variant],
          className
        )}
        initial='initial'
        animate={isLoading ? 'loading' : isHovered ? 'hover' : 'initial'}
        variants={containerVariants}
        transition={{ type: 'spring', stiffness: 150, damping: 20, mass: 0.8 }}
        onMouseEnter={() => setIsHovered(true)}
        onMouseLeave={() => setIsHovered(false)}
        onFocus={() => setIsHovered(true)}
        onBlur={() => setIsHovered(false)}
        onClick={handleClick}
        disabled={isLoading}
        {...props}
        aria-label={text}
      >
        <div className='grid h-12 w-12 place-items-center shrink-0 z-10'>
          <AnimatePresence mode='popLayout'>
            {isLoading ? (
              <motion.div
                key='spinner'
                initial={{ opacity: 0, scale: 0.5, rotate: -90 }}
                animate={{ opacity: 1, scale: 1, rotate: 0 }}
                exit={{ opacity: 0, scale: 0.5 }}
                transition={{ duration: 0.2 }}
              >
                <Loader2 className='h-5 w-5 animate-spin' />
              </motion.div>
            ) : (
              <motion.div
                key='icon'
                initial={{ opacity: 0, scale: 0.5 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.5 }}
                transition={{ duration: 0.2 }}
              >
                {icon || <Plus className='h-5 w-5' />}
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        <motion.div variants={textVariants} className='pr-6 pl-1'>
          {text}
        </motion.div>
      </motion.button>
    );
  }
);

MicroExpander.displayName = 'MicroExpander';

export { MicroExpander };

Usage

Import the component and pass the required text prop. You can customize the icon, variant, and loading state as needed.

import { MicroExpander } from '@/components/ui/micro-expander';
import { Trash2, Save } from 'lucide-react';
import { useState } from 'react';

export function MicroExpanderDemo() {
  const [isLoading, setIsLoading] = useState(false);

  return (
    <div className='flex flex-col gap-8 items-center'>
      {/* Default Usage */}
      <MicroExpander text='Create New' />

      {/* Custom Variant & Icon */}
      <MicroExpander
        text='Delete Item'
        variant='destructive'
        icon={<Trash2 className='h-5 w-5' />}
      />

      {/* Loading State Example */}
      <MicroExpander
        text='Save Changes'
        variant='outline'
        icon={<Save className='h-5 w-5' />}
        isLoading={isLoading}
        onClick={() => setIsLoading(!isLoading)}
      />
    </div>
  );
}

Props

PropTypeDefaultDescription
textstringRequiredThe label text to display when the button is hovered/expanded.
iconReact.ReactNode<Plus />An optional custom icon. Defaults to a Plus icon if not provided.
variant"default" | "outline" | "ghost" | "destructive""default"The visual style variant of the button.
isLoadingbooleanfalseIf true, displays a spinner, disables interaction, and collapses the button.