# Flip Button

> A pill button whose label flips up on hover while the icon in its badge slides out and back in.

- Page: https://ui.devigner.cc/components/flip-button
- Category: Marketing
- Requirements: React 18 or 19, Tailwind CSS v4
- Install: `npx devignerui add flip-button` (writes components/ui/flip-button.tsx)
- Packages it needs: cn, @devigner-ui/icons, devignerui

## What the demo shows

The live demo is a row of pill buttons, one per variant. Hover one and its label flips up as a copy rolls in from below, while the arrow in its badge slides out to the right as another slides in from the left, one frame behind the label.

## When to use it

- Calls to action that should feel alive on hover without moving the layout, such as a hero, pricing or Explore all link.
- A set of buttons that need one shared look across several roles: primary, secondary, outline, glass, inverse and ghost.

## When to reach for something else

- Dense toolbars and table rows, where every hover would flip a label; use a plain button.
- Destructive actions such as deleting data. There is no destructive variant; use Delete Button, which asks for confirmation.

## Usage

```tsx
import { FlipButton } from "@/components/ui/flip-button";

<FlipButton iconRight={<IconArrowRight />}>Get started</FlipButton>
```

Custom composition:

```tsx
<FlipButton variant="outline" iconRight={<IconAdd />} iconMotion="rotate">New project</FlipButton>
```

### Link with a turning plus

A link styled as an outline pill whose plus turns on hover. Next's Link works the same way.

```tsx
import { IconAdd } from "@devigner-ui/icons";
import { FlipButton } from "@/components/ui/flip-button";

export function NewProject() {
  return (
    <FlipButton asChild variant="outline" iconRight={<IconAdd />} iconMotion="rotate">
      <a href="/projects/new">New project</a>
    </FlipButton>
  );
}
```

### Copy button that confirms

The label rolls to Copied for two seconds after a click. Hover does nothing while flipped is passed.

```tsx
import * as React from "react";
import { IconCopy } from "@devigner-ui/icons";
import { FlipButton } from "@/components/ui/flip-button";

export function CopyLink({ url }: { url: string }) {
  const [copied, setCopied] = React.useState(false);
  return (
    <FlipButton
      variant="outline"
      iconRight={<IconCopy />}
      flipLabel="Copied"
      flipped={copied}
      onClick={async () => {
        await navigator.clipboard.writeText(url);
        setCopied(true);
        setTimeout(() => setCopied(false), 2000);
      }}
    >
      Copy link
    </FlipButton>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children | `ReactNode` | - | The label. Text or richer content (a count, an icon) flips alike. |
| flipLabel | `ReactNode` | children | The label that rolls in. Defaults to a copy of children. |
| flipped | `boolean` | - | Holds the flip from state instead of hover: true shows flipLabel (Copied, Saved). Once passed, hover and focus no longer flip. |
| flipDirection | `"up" \| "down"` | "up" | Which way the label rolls. |
| variant | `"primary" \| "secondary" \| "outline" \| "glass" \| "inverse" \| "ghost"` | "primary" | Color scheme. glass is for busy grounds such as images; inverse is the opposite of the page (dark on light, light on dark). |
| size | `"sm" \| "md" \| "lg" \| "xl" \| "icon" \| "icon-lg"` | "md" | Height: 40, 48, 64 or 64px with larger text; icon and icon-lg are 40 and 48px squares. |
| fullWidth | `boolean` | false | Stretches to the container; the label stays centred and the badge pins to the trailing edge. |
| asChild | `boolean` | false | Renders its only child (a link, Next's Link) with the button's look instead of a button. |
| loading | `boolean` | false | Shows a spinner in the badge, or in place of the leading icon when there is no badge, and disables the button. |
| iconLeft | `ReactNode` | - | Leading icon, static. |
| iconRight | `ReactNode` | - | Trailing icon, set in a colored disc that rolls on hover. |
| iconMotion | `"slide" \| "rotate" \| "none"` | "slide" | How the disc's icon moves on hover: slides out as its copy enters, turns 90 degrees, or stays put. |
| disabled | `boolean` | false | Disables interaction. |
| className | `string` | - | Button classes, merged last. |

## Keyboard

| Keys | Action |
| --- | --- |
| Tab | Focuses the button and flips it like a hover; a 2px ring in the ring color is drawn outside it. |
| Enter / Space | Activates it, like any native button (Enter only when asChild renders a link). |

## Accessibility

- Renders a real <button type="button">, or with asChild the element you pass, so the role is right and the label is the children.
- The rolling copy is aria-hidden, so screen readers read the label once. With flipped the two swap, so the name is whichever label is showing.
- A flipped label (Copied) changes the button's name but is not announced; pair it with a toast or a live region when the change matters.
- Badge icons and the spinner are aria-hidden. Give an icon-only button an aria-label.
- loading sets aria-busy and disables the button. With asChild, disabled and loading set aria-disabled and take the link out of the tab order.
- Honors prefers-reduced-motion and an ancestor <MotionConfig reducedMotion="always">, so an in-app motion switch works too. With reduced motion the label and icon stay still, and flipped swaps the label without the roll.

## Theming

- Every variant uses theme tokens (primary, secondary, foreground, background, surface, border, ring), so it follows light and dark on its own.
- The label and the disc's icon share one curve, cubic-bezier(0.175, 0.885, 0.32, 1.275) (a slight overshoot that settles), set inline so no theme variable is needed; the press scale and color changes use ease-out over 200ms.
- Set --flip-duration (450ms) to change the roll's length and --flip-stagger (16.67ms) to change how far the icon trails the label, from className or style.
- Sizes own the geometry and variants own color, so a className with bg-* or text-* recolors any size.

## Edge cases

- The label and flipLabel share one grid cell, so the button is as wide as the longer one and does not resize when it flips.
- Any label flips, rich content included: it is rendered twice, so avoid ids or interactive elements inside it.
- Passing flipped, even false, hands the flip to state: hover and focus stop flipping. Leave it undefined for hover.
- In right-to-left layouts the badge icon slides the other way.
- loading puts the spinner in the badge so the width holds; without a badge it takes the leading icon's place.
- fullWidth pins the badge to the trailing edge, so a long label can run under it; keep labels short on narrow widths.
- The disc's icon slides one 60Hz frame behind the label and turns two frames behind, in and out alike.

## Troubleshooting

**The label does not flip on hover.**
Check that flipped is not passed: any boolean, false included, hands the flip to state. Also check that reduced motion is off.

**The flipped label shows below the button.**
The clip relies on the label being a single line. Keep the label short or give the button a width that fits it; whitespace-nowrap is already set.

**The component renders with no background or the wrong colors.**
Colors use the standard shadcn/ui tokens only (background, foreground, primary, secondary, muted, accent, border, input, ring, destructive), so a project set up with shadcn/ui needs nothing extra and the component follows its theme, dark mode included. Without shadcn/ui, define those CSS variables for :root and .dark and map them in your Tailwind v4 @theme, or run npx shadcn init.
