Patterns
PatternsTier 5 · Blocks
Wishlist button
The heart toggle, with outline/fill as a second signal beside colour.
bunx --bun shadcn@latest add @woohl/wishlist-buttonPreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/wishlist-buttonregistry/tier-5-blocks/wishlist-button.tsx
"use client";
import type { ReactElement } from "react";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import { focusRing } from "@/registry/tier-2-primitives/lib/cva";
/**
* The heart on a product card.
*
* The accessible name carries the product — "Save Forest Ritual Candle", not
* "Save". A grid of twelve buttons all called "Save" cannot be navigated by
* screen reader.
*
* The filled state uses the accent, not a hard-coded red, so it reads orange on
* the storefront and green in the seller dashboard without knowing either.
*/
export type WishlistButtonProps = {
/** The heart, from the app's own set: `icon={<Icon name="heart" />}`. */
icon?: ReactElement;
/** Optional distinct saved-state glyph (e.g. a filled heart). */
savedIcon?: ReactElement;
saved: boolean;
onToggle: () => void;
/** The product's name. Goes into the accessible label. */
productName: string;
size?: "sm" | "md";
/** `floating` sits on a product image; `inline` sits in a row of actions. */
variant?: "floating" | "inline";
className?: string;
};
export function WishlistButton({
icon: defaultIcon,
savedIcon,
saved,
onToggle,
productName,
size = "md",
variant = "floating",
className,
}: WishlistButtonProps) {
const box = size === "sm" ? "size-8" : "size-[var(--control-h-sm)]";
const icon = size === "sm" ? 15 : 16;
return (
<button
data-slot="wishlist-button"
type="button"
onClick={onToggle}
aria-pressed={saved}
aria-label={saved ? `Saved ${productName}` : `Save ${productName}`}
className={cn(
"motion-instant grid place-items-center rounded-full transition-[background-color,color,transform]",
"active:scale-[0.92]",
box,
variant === "floating"
? cn(
// On a photograph, so it carries its own solid container rather
// than relying on whatever the image happens to be behind it.
//
// That container is fixed-light in BOTH appearances — a
// photograph is dark either way — so its icon resolves to Ink
// rather than to --text-primary, which would flip to near-white
// in dark mode and vanish against the glass.
"bg-surface-glass hover:bg-paper backdrop-blur-[2px]",
saved ? "text-text-accent" : "text-ink",
)
: cn(
"border-border-subtle bg-surface-raised hover:border-border-strong border",
saved ? "text-text-accent" : "text-text-secondary",
),
focusRing,
className,
)}
>
{/* Filled when saved. The outline/fill difference is the second signal,
so the state survives greyscale and colour-vision deficiency — pass a
`savedIcon` that is visibly filled, or let `fill-current` do it. */}
<span
aria-hidden
className={cn("grid place-items-center", saved && "fill-current")}
style={{ width: icon, height: icon }}
>
{saved ? (savedIcon ?? defaultIcon) : defaultIcon}
</span>
</button>
);
}
WishlistButtonProps
| Prop | Type | Default |
|---|---|---|
onToggle* | () => void | — |
productName*The product's name. Goes into the accessible label. | string | — |
saved* | boolean | — |
className | string | — |
iconThe heart, from the app's own set: `icon={<Icon name="heart" />}`. | ReactElement | — |
savedIconOptional distinct saved-state glyph (e.g. a filled heart). | ReactElement | — |
size | "sm" | "md" | — |
variant`floating` sits on a product image; `inline` sits in a row of actions. | "floating" | "inline" | — |
Registry items
npm packages
None beyond React itself.