Flip Button
Button whose label leaves at the top while a copy rises from below.
From the Findit template — see it in contextimport { FlipButton } from "@/components/ui/flip-button";
export default function FlipButtonDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-4">
<FlipButton href="#">Book a viewing</FlipButton>
<FlipButton variant="outline" pill>
All listings
</FlipButton>
<FlipButton variant="ghost" size="sm">
Contact
</FlipButton>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/flip-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/flip-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"; /* * Flip Button — the label leaves at the top while a second copy rises from below (from the Findit "Button/Primary"). * Framer's soft spring drives it: spring, bounce 0, duration 0.4s. The copy is aria-hidden, so the label is read once. * Pass `href` to render a Next.js link; external http(s) links open in a new tab. */ const soft: Transition = { type: "spring", bounce: 0, duration: 0.4 }; 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 px-3", default: "h-9 px-4", lg: "h-10 px-6", } as const; type Common = { variant?: keyof typeof variantClasses; size?: keyof typeof sizeClasses; /** Pill instead of the shadcn rounded-md corner, as in the template. */ pill?: boolean; className?: string; children?: React.ReactNode; }; export type FlipButtonProps = | (Common & { href: string } & Omit<HTMLMotionProps<"a">, keyof Common | "href">) | (Common & { href?: undefined } & Omit<HTMLMotionProps<"button">, keyof Common>); const MotionLink = motion.create(Link); export function FlipButton({ variant = "default", size = "default", pill = false, className, children, ...props }: FlipButtonProps) { const motionProps = { initial: "rest", animate: "rest", whileHover: "hover", whileFocus: "hover", className: cn( "inline-flex shrink-0 cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap transition-colors select-none", pill ? "rounded-full" : "rounded-md", "outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "disabled:pointer-events-none disabled:opacity-50", variantClasses[variant], sizeClasses[size], className, ), } as const; /* The label box is one line tall (20px); the column holds both copies 8px apart, so one flip travels 28px. */ const label = ( <span className="relative block h-5 overflow-hidden"> <motion.span className="flex flex-col gap-2" variants={{ rest: { y: 0 }, hover: { y: -28 } }} transition={soft} > <span className="block leading-5">{children}</span> <span aria-hidden className="block leading-5"> {children} </span> </motion.span> </span> ); 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} > {label} </MotionLink> ); } const { type = "button", ...rest } = props as HTMLMotionProps<"button">; return ( <motion.button type={type} {...rest} {...motionProps}> {label} </motion.button> ); }
Usage
import { FlipButton } from "@/components/ui/flip-button";
<FlipButton href="/contact">Book a viewing</FlipButton>
<FlipButton variant="outline" pill onClick={save}>Save</FlipButton>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "secondary" | "outline" | "ghost" | "default" | shadcn button styles. |
| size | "sm" | "default" | "lg" | "default" | Height 32 / 36 / 40 px, like the shadcn button. |
| pill | boolean | false | Fully round corners, as in the template. |
| 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). |
- The second copy of the label is aria-hidden, so a screen reader reads the label once.
- The flip also runs on keyboard focus.
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 FlipButton. - Look like the shadcn/ui Button: rounded-md (or rounded-full with a "pill" prop), 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. - The label sits in a box one line tall (h-5) with overflow-hidden, holding a column of two identical labels 8px apart. - On hover and on keyboard focus the column moves up 28px with a spring (bounce 0, duration 0.4s): the first label leaves at the top while the copy arrives from below. The copy is aria-hidden. - If an href prop is given render a Next.js Link (motion.create(Link)); external http(s) links get target _blank and rel noopener noreferrer.











