All patterns

Like Button

The heart takes on its fill and settles once — no particle spray, no confetti.

socialfriendlyenergeticinteraction · finite · starter · ~0.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

224 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Like Button
 *
 * The glyph fills and settles once. No particle spray: a like is the
 * cheapest action in any product, it fires dozens of times per session,
 * and motion that is delightful on the first tap is exhausting on the
 * twentieth. What moves is the heart taking on its fill and the count
 * stepping by one.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The pill is mixed from the inherited text color, so it reads correctly
 * on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `baseCount`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type LikeButtonBurstProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Uncontrolled starting state. */
  defaultLiked?: boolean;
  /** Count before the viewer's own like. */
  baseCount?: number;
  /** Accessible verb for the control. */
  label?: string;
  /** Fires on every toggle, with the new state. */
  onLikedChange?: (liked: boolean) => void;
};

type VariantConfig = {
  /** Scale the filled glyph starts from. */
  from: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** px the count travels as it rolls. */
  roll: number;
  rollSeconds: number;
};

// Every spring here is at or above a 0.8 damping ratio
// (damping / 2√stiffness), so the fill settles once. A glyph this small
// turns visible wobble into a rendering glitch rather than personality —
// and the variants differ in travel and speed, never in bounce count.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost a crossfade. For a dense feed where every row has one.
  subtle: {
    from: 0.82,
    spring: { type: "spring", stiffness: 520, damping: 42 },
    roll: 9,
    rollSeconds: 0.2,
  },
  // One soft settle — enough to feel like the tap did something.
  default: {
    from: 0.68,
    spring: { type: "spring", stiffness: 420, damping: 34 },
    roll: 12,
    rollSeconds: 0.24,
  },
  // More travel into the settle, for a detail view where the like is
  // the primary action on screen.
  playful: {
    from: 0.55,
    spring: { type: "spring", stiffness: 360, damping: 31 },
    roll: 14,
    rollSeconds: 0.26,
  },
};

/** Semantic color: a like is red in every product that has one. */
const LIKE = "#E8365D";

const HEART =
  "M12 20.6C12 20.6 3.2 15.1 3.2 9.2 3.2 6.3 5.5 4.2 8.2 4.2 9.9 4.2 11.3 5.1 12 6.4 12.7 5.1 14.1 4.2 15.8 4.2 18.5 4.2 20.8 6.3 20.8 9.2 20.8 15.1 12 20.6 12 20.6Z";

/** Theme-adaptive neutral: `currentColor` is the text color in scope —
 *  near-black on a light page, near-white on a dark one — so mixing it
 *  with `transparent` yields a surface or border correctly toned in
 *  either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function LikeButtonBurst({
  variant = "default",
  defaultLiked = false,
  baseCount = 1284,
  label = "Like",
  onLikedChange,
}: LikeButtonBurstProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [liked, setLiked] = useState(defaultLiked);

  const count = baseCount + (liked ? 1 : 0);

  const toggle = () => {
    const next = !liked;
    setLiked(next);
    onLikedChange?.(next);
  };

  // Reduced motion: the fill still arrives and the number still changes.
  // Only the travel to get there is dropped — the state is never carried
  // by movement alone.
  const fillTransition = reduceMotion
    ? { duration: 0.16, ease: "easeOut" as const }
    : { ...cfg.spring, opacity: { duration: 0.14, ease: "easeOut" as const } };

  // Direction of the roll follows the direction of the change: up when
  // the number grows, down when the like is taken back.
  const rollFrom = reduceMotion ? 0 : liked ? cfg.roll : -cfg.roll;

  return (
    <button
      type="button"
      onClick={toggle}
      aria-pressed={liked}
      aria-label={`${label} · ${count.toLocaleString()} likes`}
      style={{
        display: "inline-flex",
        alignItems: "center",
        gap: 9,
        padding: "7px 13px 7px 10px",
        borderRadius: 999,
        border: `1px solid ${tone(13)}`,
        background: tone(5),
        color: "inherit",
        fontFamily: "inherit",
        fontSize: 13.5,
        lineHeight: 1,
        cursor: "pointer",
      }}
    >
      <span
        aria-hidden
        style={{
          position: "relative",
          display: "block",
          width: 20,
          height: 20,
        }}
      >
        {/* Two stacked copies of one path: the outline hands over to the
            fill. Crossfading them is cheaper to reason about than
            interpolating a stroke into a fill, and it keeps the resting
            state theme-adaptive via `currentColor`. */}
        <svg
          width="20"
          height="20"
          viewBox="0 0 24 24"
          fill="none"
          style={{ position: "absolute", inset: 0, display: "block" }}
        >
          <motion.path
            d={HEART}
            stroke="currentColor"
            strokeWidth="1.7"
            strokeLinejoin="round"
            initial={false}
            animate={{ opacity: liked ? 0 : 0.55 }}
            transition={{ duration: 0.14, ease: "easeOut" }}
          />
        </svg>
        <svg
          width="20"
          height="20"
          viewBox="0 0 24 24"
          style={{ position: "absolute", inset: 0, display: "block" }}
        >
          <motion.path
            d={HEART}
            fill={LIKE}
            initial={false}
            animate={{ scale: liked ? 1 : cfg.from, opacity: liked ? 1 : 0 }}
            transition={fillTransition}
            // `fill-box` makes the origin relative to the glyph itself
            // rather than the SVG viewport, so it scales about its own
            // centre of mass rather than about the corner.
            style={{ transformBox: "fill-box", transformOrigin: "50% 55%" }}
          />
        </svg>
      </span>

      {/* The count is a number, so it rolls at a constant size — text
          that scales or bounces is the fastest way to make a UI look
          cheap. The button's aria-label already carries the value. */}
      <span
        aria-hidden
        style={{
          position: "relative",
          display: "block",
          minWidth: 40,
          height: 16,
          overflow: "hidden",
          fontVariantNumeric: "tabular-nums",
        }}
      >
        <AnimatePresence initial={false}>
          <motion.span
            key={count}
            initial={{ y: rollFrom, opacity: 0 }}
            animate={{ y: 0, opacity: 1 }}
            exit={{ y: -rollFrom, opacity: 0 }}
            transition={{
              duration: reduceMotion ? 0.14 : cfg.rollSeconds,
              ease: [0.32, 0.72, 0, 1],
            }}
            style={{
              position: "absolute",
              left: 0,
              top: 0,
              display: "block",
              lineHeight: "16px",
              opacity: 0.75,
            }}
          >
            {count.toLocaleString()}
          </motion.span>
        </AnimatePresence>
      </span>
    </button>
  );
}

About this pattern

A like is the cheapest action in a product: it fires dozens of times per session, often twice in a row when someone changes their mind. Motion that is delightful on the first tap is exhausting on the twentieth, so this deliberately does not spray particles. The outline glyph hands over to a filled one that arrives with a single soft settle, and the count steps by one on a short vertical roll at a constant size. Everything else in the row stays still.

Liking a postFavouriting an itemUpvoting a replySaving a track

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Priya Raman2hFinally got the trail loop under an hour. Four months of Tuesdays.
    12814
    Marcus Bell5hNew supplier signed. Same rate, twelve more months.
    423
    Comment thread

    The glyph fills and the count beside it steps up by one.

Related patterns