Motiva
Мои коллекции
← каталог

Like Burst

Кнопка-лайк: по клику сердце пружинит, вокруг разлетаются и гаснут цветные частицы.

motionclickspringintermediate~18 КБreduced-motion ✓

Установка

npx shadcn@latest add https://motiva.pages.dev/r/like-burst.json

зависимости: motion@^12.42.2

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useState } from "react";
import { motion } from "motion/react";

const PARTICLES = 9;

/** Кнопка-лайк: по клику сердце пружинит, вокруг разлетаются и гаснут частицы. Reduced-motion — просто смена цвета. */
export function LikeBurst() {
  const [liked, setLiked] = useState(false);
  const [bursts, setBursts] = useState<number[]>([]);
  const frozen =
    typeof document !== "undefined" &&
    (document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches);

  const toggle = () => {
    const next = !liked;
    setLiked(next);
    if (next && !frozen) {
      const id = performance.now();
      setBursts((b) => [...b, id]);
      setTimeout(() => setBursts((b) => b.filter((x) => x !== id)), 900);
    }
  };

  return (
    <button
      onClick={toggle}
      aria-pressed={liked}
      aria-label={liked ? "Убрать лайк" : "Поставить лайк"}
      style={{
        position: "relative",
        width: 84,
        height: 84,
        borderRadius: "50%",
        border: "1px solid oklch(0.32 0.02 270)",
        background: "oklch(0.2 0.02 270)",
        cursor: "pointer",
        display: "grid",
        placeItems: "center",
      }}
    >
      <motion.span
        key={String(liked)}
        initial={frozen ? false : { scale: liked ? 0.4 : 1 }}
        animate={{ scale: 1 }}
        transition={frozen ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 14 }}
        style={{ fontSize: 34, lineHeight: 1, filter: liked ? "none" : "grayscale(1) opacity(0.55)" }}
      >
        ❤️
      </motion.span>
      {bursts.map((id) => (
        <span key={id} aria-hidden style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
          {Array.from({ length: PARTICLES }, (_, i) => {
            const angle = (i / PARTICLES) * Math.PI * 2;
            const dist = 46 + (i % 3) * 12;
            return (
              <motion.i
                key={i}
                initial={{ x: 0, y: 0, scale: 1, opacity: 1 }}
                animate={{
                  x: Math.cos(angle) * dist,
                  y: Math.sin(angle) * dist,
                  scale: 0,
                  opacity: 0,
                }}
                transition={{ duration: 0.75, ease: [0.16, 1, 0.3, 1] }}
                style={{
                  position: "absolute",
                  left: "50%",
                  top: "50%",
                  width: 7,
                  height: 7,
                  marginLeft: -3.5,
                  marginTop: -3.5,
                  borderRadius: i % 2 ? "50%" : 2,
                  background: ["oklch(0.72 0.19 20)", "oklch(0.75 0.16 300)", "oklch(0.78 0.14 210)"][i % 3],
                }}
              />
            );
          })}
        </span>
      ))}
    </button>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0