shadimage
easeui
componentbuttonsource: ok · hit

Button

Button with a quick press, hairline rings instead of borders, four variants, and a 44px tap area even at small sizes.

npx shadcn add "https://easeui.dev/r/button.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files2 · source cached 10/9/2026, 10:49:37 AM

components/motion/button.tsx
"use client";
// easeui.dev/components/motion/button

import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react";
import { forwardRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";

export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type ButtonSize = "sm" | "md" | "lg" | "icon";

export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children"> {
  /** Visual style. Default "primary". */
  variant?: ButtonVariant;
  /** Height and padding. "icon" is a square button for a single icon. Default "md". */
  size?: ButtonSize;
  children?: ReactNode;
}

// A quick press confirms the tap before the action finishes.
const PRESS = { scale: 0.97 };
const NO_PRESS = { scale: 1 };
const PRESS_TRANSITION = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const;

// Hairline rings are drawn with box-shadow so they blend with any background.
const VARIANT_CLASS: Record<ButtonVariant, string> = {
  primary: "bg-foreground text-background hover:bg-foreground/90",
  secondary:
    "bg-card text-foreground shadow-[0_0_0_1px_var(--border)] hover:bg-muted",
  outline:
    "text-foreground shadow-[0_0_0_1px_var(--border-strong)] hover:bg-foreground/5",
  ghost: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground",
};

// Small sizes grow an invisible hit area so the tap target stays around 44px.
const SIZE_CLASS: Record<ButtonSize, string> = {
  sm: "h-8 gap-1.5 px-3 text-xs after:absolute after:-inset-1.5",
  md: "h-10 gap-2 px-4 text-sm",
  lg: "h-12 gap-2 px-5 text-base",
  icon: "h-9 w-9 after:absolute after:-inset-1",
};

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { variant = "primary", size = "md", type = "button", className, children, ...props },
  ref,
) {
  const reduce = useReducedMotion();

  return (
    <motion.button
      ref={ref}
      type={type}
      // Always pass a gesture so the server and client render the same attributes.
      whileTap={reduce ? NO_PRESS : PRESS}
      transition={PRESS_TRANSITION}
      className={cn(
        "group relative inline-flex shrink-0 touch-manipulation select-none items-center justify-center rounded-full font-medium outline-none will-change-transform",
        "transition-[background-color,color,box-shadow] duration-150 ease-out",
        "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        "disabled:pointer-events-none disabled:opacity-50",
        VARIANT_CLASS[variant],
        SIZE_CLASS[size],
        className,
      )}
      {...props}
    >
      {children}
    </motion.button>
  );
});
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

More from easeui

Browse all components in this library →