# Favorite button

> A React favorite button: a heart that fills with a spring pop and a burst, keeps a count, saves optimistically and rolls back on failure.

- Type: React component (Commerce)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add favorite-button`
- Docs: https://wingo-ui.com/components/favorite-button

## Interaction

Tap or click the heart to save: it dips, pops past its size on a bouncy spring, fills in and throws a ring and a handful of particles; tap again and the fill shrinks away. With a count the number rolls up or down by one right away. While the save request is pending further taps are ignored; when it fails the heart shakes, goes back to how it was and the error is read out. On a phone a light tick answers the tap, every size has a 44px target, and the glass heart on a photo sits above the card's link so it never opens the listing. With a mouse the action shows as a tooltip. Tab to it and press Space or Enter to toggle; screen readers hear one constant name, the pressed state and the count.

## Installation

```bash
npx wingo-ui@latest add favorite-button
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/favorite-button` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/favorite-button.tsx`

npm dependencies: `npm i lucide-react motion`

Wingo UI dependencies (installed automatically):

- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { FavoriteButton } from "@/components/ui/favorite-button"

const labels = {
  name: "Save listing",
  add: "Add to your watchlist",
  remove: "Remove from your watchlist",
  error: "We could not save it. Try again.",
}

export function ListingPhoto({ saved, onSave }: { saved: boolean; onSave: (saved: boolean) => Promise<unknown> }) {
  return (
    <div className="relative aspect-[4/3] overflow-hidden rounded-2xl bg-foreground/5">
      <img src="/demo/bicycle.jpg" alt="Pegas Popular city bike" className="size-full object-cover" />
      <FavoriteButton
        variant="overlay"
        tone="danger"
        pressed={saved}
        onPressedChange={onSave}
        labels={labels}
        className="absolute end-2.5 top-2.5"
      />
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultPressed?` | `boolean` | `false` | Starts saved. The button then owns its state. |
| `pressed?` | `boolean` |  | Controlled on / off. While a request is pending, a change of this prop wins and the pending answer is ignored. |
| `onPressedChange?` | `((pressed: boolean) => void \| Promise<unknown>)` |  | Called with the new state when a person toggles it. Return a promise to keep the optimistic state while it is pending (clicks are ignored meanwhile); a rejection, or a synchronous throw, rolls back, shakes, buzzes on touch and announces labels.error. |
| `variant?` | `"ghost" \| "overlay" \| "soft" \| "outline"` | `"ghost"` | ghost: a bare icon button for rows and headers. overlay: a round glass button for photos (white heart, black glass in both themes). soft and outline: a chip for counts and labels. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Icon only: 32 / 40 / 48px (the overlay 32 / 36 / 44px) with a 16 / 20 / 22px heart; md grows to 44px on touch and the rest get a 44px hit area. Chips take the button heights. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"full"` | Corner shape, scaled per size like the button. full is a circle, or a pill for chips. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The on color. neutral fills black / white (white on photos); the classic red heart is danger. The chip tints use the tone's soft colors. |
| `color?` | `string` | `""` | Any CSS color for the filled heart, the burst and the chip tint. Replaces the tone. |
| `icon?` | `ReactNode` | `"<Heart />"` | Any lucide icon: ThumbsUp for a helpful vote, Bookmark for saved searches, Star. The on copy is drawn with fill-current. |
| `count?` | `number` | `0` | The total including this person's vote, as the server knows it. The shown number moves by one right away when toggled and never goes below 0. Compact from 10.000 (12,4 K). |
| `showCount?` | `boolean` | `false` | Shows the count beside the icon; it rolls up or down when it changes and is described to screen readers. |
| `showLabel?` | `boolean` | `false` | Shows labels.save / labels.saved beside the icon. Both share one cell, so the chip never changes width. The accessible name stays constant (labels.name, or labels.save when no name is given) while aria-pressed flips. |
| `burst?` | `boolean` | `true` | A ring and particles when a person turns it on. Never on mount, on a prop change or under reduced motion. |
| `particleCount?` | `number` | `8` | How many particles the burst throws, spread evenly around the heart with a fixed jitter. |
| `tooltip?` | `boolean` | `true` | Shows the action (labels.add / labels.remove) as a tooltip on hover and focus. Never on touch. |
| `haptics?` | `boolean` | `true` | A light tick on touch when it turns on and an error buzz on rollback. Never from a mouse or on mount. |
| `disabled?` | `boolean` | `false` | Dims the button and blocks it. |
| `locale?` | `string` | `""` | Formats the count (1.284 in ro-RO, 1,284 in en-US). Empty uses the UIProvider locale, then the browser's. |
| `labels?` | `Partial<FavoriteButtonLabels>` |  | Every string: name "Save" (the constant accessible name), add "Add to favorites", remove "Remove from favorites", save "Save", saved "Saved", error "Could not save. Try again.", count (n, formatted) => "128 saves". Pass only the keys you translate. |
| `classNames?` | `Partial<Record<"icon" \| "burst" \| "label" \| "count", string>>` |  | Extra classes for the inner parts. |
| `type?` | `"button" \| "submit" \| "reset"` | `"button"` | Defaults to "button" so the heart never submits a surrounding form. |
| `onClick?` | `(event: MouseEvent<HTMLButtonElement>) => void` |  | Runs before the toggle; event.preventDefault() cancels it. |
| `className?` | `string` |  | Extra classes for the button, merged last. |

## Parts

### DEFAULT_FAVORITE_BUTTON_LABELS (constant)

The English copy: name "Save", add "Add to favorites", remove "Remove from favorites", saved "Saved", the error message and the count text. Spread it into labels to translate a few keys.

## Examples

- **Variants**: ghost for rows, overlay on photos, soft with a count, outline with a label.
- **Red heart**: tone="danger" in the three sizes, bare and on a photo.
- **Optimistic save that fails**: Every second save rejects after 700ms: the heart shakes, rolls back and the error is announced.
- **Helpful vote**: A thumbs up with no burst, a label and a count: the vote under a review.
- **Saved search**: A bookmark with a label; the chip keeps its width from Save search to Search saved.
- **Favorites counter**: Controlled hearts that keep a badge in sync.
- **Custom color**: #E11D48 with 12 particles, on a photo, bare and as a chip.
- **Big counts**: Compact from 10,000: 12,4 K in ro-RO, 12.4K in en-US. Tap to watch the number roll.

## See also

- [Listing card](https://wingo-ui.com/components/listing-card) ([Markdown](https://wingo-ui.com/components/listing-card.md))
- [Reviews](https://wingo-ui.com/components/reviews) ([Markdown](https://wingo-ui.com/components/reviews.md))
- [Toggle](https://wingo-ui.com/components/toggle) ([Markdown](https://wingo-ui.com/components/toggle.md))
- [Emoji Reaction](https://wingo-ui.com/components/emoji-reaction) ([Markdown](https://wingo-ui.com/components/emoji-reaction.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))

---

Canonical: https://wingo-ui.com/components/favorite-button
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
