点赞爆发
点击爱心时,它会先压扁再弹回并填满颜色,同时向外迸出一圈小火花。
用 CSS 为 React 制作带爆发效果的点赞按钮。按钮内放一个爱心 SVG,并切换 aria-pressed。开启时:爱心在 450ms 内按 1 → 0.8 → 1.15 → 1 缩放,缓动为 cubic-bezier(0.16, 1, 0.3, 1),填充色从透明变为强调色;同时播放一圈环绕爱心的 8 个小圆点(rotate(calc(var(--i) * 45deg)) translateY(-10px)),在 500ms 内向外移动到 translateY(-30px),透明度从 1 变为 0。每次点赞都通过改变 key 重新播放爆发。取消点赞时只清空爱心,不播放爆发。旁边用 tabular-nums 显示点赞数。aria-label 为“点赞”,点赞后更新为“已点赞”。在 prefers-reduced-motion 下跳过缩放和火花,只改变填充色。
源代码
'use client'
import { useState } from 'react'
import s from './demo.module.css'
export default function LikeBurst({ labels }: { labels: Record<string, string> }) {
const [liked, setLiked] = useState(false)
const [burst, setBurst] = useState(0)
const toggle = () => {
if (!liked) setBurst(burst + 1)
setLiked(!liked)
}
return (
<button type="button" className={s.button} aria-pressed={liked} aria-label={liked ? labels.liked : labels.like} onClick={toggle}>
<span className={s.icon}>
<svg key={burst} className={s.heart} viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 20.5s-7.5-4.6-7.5-10.2A4.3 4.3 0 0 1 12 7.6a4.3 4.3 0 0 1 7.5 2.7c0 5.6-7.5 10.2-7.5 10.2Z" />
</svg>
{liked && (
<span key={`b${burst}`} className={s.burst} aria-hidden="true">
{Array.from({ length: 8 }, (_, i) => (
<i key={i} style={{ '--i': i } as React.CSSProperties} />
))}
</span>
)}
</span>
<span className={s.count}>{liked ? 129 : 128}</span>
</button>
)
}
.button {
display: inline-flex;
align-items: center;
gap: 10px;
height: 52px;
padding: 0 20px 0 16px;
border: 1px solid var(--stage-rule);
border-radius: 999px;
background: var(--stage-surface);
color: var(--stage-ink);
font: 600 var(--text-base) / 1 var(--font-body);
cursor: pointer;
}
.button:hover { border-color: var(--stage-muted); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }
.icon { position: relative; display: grid; place-items: center; width: 26px; height: 26px; }
.heart { width: 26px; height: 26px; overflow: visible; }
.heart path {
fill: transparent;
stroke: var(--stage-ink);
stroke-width: 1.8;
transition: fill 150ms linear, stroke 150ms linear;
}
.button[aria-pressed='true'] .heart { animation: pop 450ms var(--ease-out); }
.button[aria-pressed='true'] .heart path { fill: var(--stage-accent); stroke: var(--stage-accent); }
@keyframes pop {
0% { transform: scale(1); }
25% { transform: scale(0.8); }
60% { transform: scale(1.15); }
100% { transform: scale(1); }
}
.burst { position: absolute; inset: 0; pointer-events: none; }
.burst i {
position: absolute;
top: 50%;
left: 50%;
width: 5px;
height: 5px;
margin: -2.5px;
border-radius: 50%;
background: var(--stage-accent);
transform: rotate(calc(var(--i) * 45deg)) translateY(-10px);
animation: spark 500ms var(--ease-out) forwards;
}
.burst i:nth-child(even) { background: var(--stage-ink); }
@keyframes spark {
to { transform: rotate(calc(var(--i) * 45deg)) translateY(-30px); opacity: 0; }
}
.count { font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) {
.button[aria-pressed='true'] .heart { animation: none; }
.burst { display: none; }
}