← каталог
Like Burst
Кнопка-лайк: по клику сердце пружинит, вокруг разлетаются и гаснут цветные частицы.
motionclickspringintermediate~18 КБreduced-motion ✓
Установка
npx shadcn@latest add https://motiva.pages.dev/r/like-burst.jsonзависимости: motion@^12.42.2
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}
/**
* Like Burst — vanilla JS, ноль зависимостей (WAAPI). Клик: сердце пружинит,
* вокруг разлетаются и гаснут частицы.
*
* <button id="like" style="position:relative"><span data-heart>❤️</span></button>
* <script type="module">
* import { initLikeBurst } from './like-burst.vanilla.js';
* initLikeBurst(document.querySelector('#like'));
* </script>
*
* @param {HTMLElement} btn — кнопка с [data-heart] внутри (position:relative)
* @param {{ particles?: number, colors?: string[], onChange?: (on: boolean) => void }} [options]
* @returns {() => void} destroy
*/
export function initLikeBurst(btn, options = {}) {
const {
particles = 9,
colors = ["oklch(0.72 0.19 20)", "oklch(0.75 0.16 300)", "oklch(0.78 0.14 210)"],
onChange,
} = options;
const heart = btn.querySelector("[data-heart]") ?? btn;
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const burst = () => {
for (let i = 0; i < particles; i++) {
const angle = (i / particles) * Math.PI * 2;
const dist = 46 + (i % 3) * 12;
const p = document.createElement("i");
p.style.cssText = `position:absolute;left:50%;top:50%;width:7px;height:7px;margin:-3.5px 0 0 -3.5px;
border-radius:${i % 2 ? "50%" : "2px"};background:${colors[i % colors.length]};pointer-events:none;`;
btn.appendChild(p);
p.animate(
[
{ transform: "translate(0,0) scale(1)", opacity: 1 },
{ transform: `translate(${Math.cos(angle) * dist}px, ${Math.sin(angle) * dist}px) scale(0)`, opacity: 0 },
],
{ duration: 750, easing: "cubic-bezier(0.16, 1, 0.3, 1)", fill: "forwards" },
).onfinish = () => p.remove();
}
};
const toggle = () => {
const on = btn.getAttribute("aria-pressed") !== "true";
btn.setAttribute("aria-pressed", String(on));
heart.style.filter = on ? "none" : "grayscale(1) opacity(0.55)";
onChange?.(on);
if (!on || frozen()) return;
heart.animate(
[{ transform: "scale(0.4)" }, { transform: "scale(1.15)", offset: 0.6 }, { transform: "scale(1)" }],
{ duration: 450, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)" },
);
burst();
};
heart.style.filter = btn.getAttribute("aria-pressed") === "true" ? "none" : "grayscale(1) opacity(0.55)";
btn.addEventListener("click", toggle);
return () => btn.removeEventListener("click", toggle);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0