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 · mediumanimationmotion
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))
}