Spring Button
Button whose arrow turns on hover, with a soft lift and a press spring.
From the Nitip template — see it in contextimport { SpringButton } from "@/components/ui/spring-button";
export default function SpringButtonDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-4">
<SpringButton>Get started</SpringButton>
<SpringButton variant="outline">View work</SpringButton>
<SpringButton variant="ghost" arrow={false}>
Contact
</SpringButton>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/spring-button.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/spring-button.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { motion, type HTMLMotionProps, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; /* * Spring Button — a shadcn-style button whose arrow turns on hover (motion from the Nitip "Primary Button"). * Hover: the arrow rotates 45°, filled variants lift with a soft shadow. Press: scales to 97%. * Pass `href` to render a Next.js link; external http(s) links open in a new tab. */ const spring: Transition = { type: "spring", bounce: 0.2, duration: 0.4 }; /* The rest state keeps the same shadow layers at 0 alpha so the shadow can animate in. */ const shadow = (alpha: number) => [ `0px 0px 20px 0px rgba(5, 5, 5, ${0.06 * alpha})`, `0px 4px 4px 0px rgba(5, 5, 5, ${0.04 * alpha})`, `0px 10px 10px 0px rgba(5, 5, 5, ${0.1 * alpha})`, ].join(", "); const variantClasses = { default: "bg-primary text-primary-foreground hover:bg-primary/90", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", } as const; const sizeClasses = { sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5", default: "h-9 gap-2 px-4 py-2 has-[>svg]:px-3", lg: "h-10 gap-2 px-6 has-[>svg]:px-4", } as const; const lifts = new Set<keyof typeof variantClasses>(["default", "secondary"]); type Common = { variant?: keyof typeof variantClasses; size?: keyof typeof sizeClasses; /** Show the turning arrow. */ arrow?: boolean; className?: string; children?: React.ReactNode; }; export type SpringButtonProps = | (Common & { href: string } & Omit<HTMLMotionProps<"a">, keyof Common | "href">) | (Common & { href?: undefined } & Omit<HTMLMotionProps<"button">, keyof Common>); const MotionLink = motion.create(Link); function Arrow() { return ( <motion.svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" variants={{ rest: { rotate: 0 }, hover: { rotate: 45 } }} transition={spring} > <path d="M4.5 11.5l7-7M5.5 4.5h6v6" /> </motion.svg> ); } export function SpringButton({ variant = "default", size = "default", arrow = true, className, children, ...props }: SpringButtonProps) { const lift = lifts.has(variant); const motionProps = { initial: "rest", animate: "rest", whileHover: "hover", whileTap: "press", variants: { rest: { scale: 1, ...(lift && { boxShadow: shadow(0) }) }, hover: { scale: 1, ...(lift && { boxShadow: shadow(1) }) }, press: { scale: 0.97 }, }, transition: spring, className: cn( "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md text-sm font-medium whitespace-nowrap transition-colors select-none", "outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", variantClasses[variant], sizeClasses[size], className, ), } as const; const content = ( <> {children} {arrow && <Arrow />} </> ); if (props.href !== undefined) { const { href, ...rest } = props; const external = /^https?:\/\//.test(href); return ( <MotionLink href={href} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} {...rest} {...motionProps} > {content} </MotionLink> ); } const { type = "button", ...rest } = props as HTMLMotionProps<"button">; return ( <motion.button type={type} {...rest} {...motionProps}> {content} </motion.button> ); }
Usage
import { SpringButton } from "@/components/ui/spring-button";
<SpringButton href="/contact">Get started</SpringButton>
<SpringButton variant="outline" onClick={save}>Save</SpringButton>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "secondary" | "outline" | "ghost" | "default" | shadcn button styles. Default and secondary lift with a shadow on hover. |
| size | "sm" | "default" | "lg" | "default" | Height 32 / 36 / 40 px, like the shadcn button. |
| arrow | boolean | true | Show the arrow that turns 45° on hover. |
| href | string | — | Render a Next.js link instead of a button. http(s) links open in a new tab. |
| …props | button / anchor props | — | Passed to the element (onClick, disabled, aria-*, Motion props). |
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called SpringButton. - Look exactly like the shadcn/ui Button: rounded-md, text-sm font-medium, variants default / secondary / outline / ghost, sizes sm (h-8 px-3), default (h-9 px-4), lg (h-10 px-6), focus ring ring-[3px] ring-ring/50. - A 16px up-right arrow icon after the label. On hover it rotates 45° so it points right, with a spring (bounce 0.2, duration 0.4s). - Default and secondary variants: on hover a soft layered shadow fades in (animate box-shadow from the same layers at 0 alpha). - On press scale to 0.97 with the same spring. - If an href prop is given render a Next.js Link (motion.create(Link)); external http(s) links get target _blank and rel noopener. - Use only the shadcn CSS variables (primary, secondary, accent, border, input, ring) so it follows the theme.











