效果演示

文末可一键复制完整代码
源代码
<!DOCTYPE html><htmllang="zh-CN"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>3D旋转心形按钮</title><style> * { border: 0; box-sizing: border-box; margin: 0; padding: 0; } :root { --hue: 223; --gray50: hsl(var(--hue) 10%95%); --gray50-01: hsl(var(--hue) 10%95% / 0.1); --gray250: hsl(var(--hue) 10%75%); --gray650: hsl(var(--hue) 10%35%); --gray950: hsl(var(--hue) 10%5%); --gray950-01: hsl(var(--hue) 10%5% / 0.1); --red400: hsl(390%60%); --red400-015: hsl(390%60% / 0.15); --red500: hsl(390%50%); --red500-015: hsl(390%50% / 0.15); --trans-dur: 0.3s; color-scheme: light dark; font-size: clamp(1rem, 0.95rem + 0.25vw, 1.25rem); } body, button { color: light-dark(var(--gray950), var(--gray50)); font: 1em/1.5 sans-serif; transition: background-color var(--trans-dur), color var(--trans-dur); } body { background-color: light-dark(var(--gray50), var(--gray950)); display: grid; place-items: center; height: 100vh; } .heart { background-color: transparent; border-radius: 50%; color: light-dark(var(--gray250), var(--gray650)); cursor: pointer; display: grid; outline: transparent; place-items: center; width: 12em; height: auto; -webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent; } .heart__svg { display: block; width: 100%; height: auto; } .heart__paths { transition: stroke var(--trans-dur); } .heart__paths--liked { stroke: light-dark(var(--red500), var(--red400)) !important; } .heart:focus-visible, .heart:hover { background-color: light-dark(var(--gray950-01), var(--gray50-01)); } .heart:focus-visible.heart__paths, .heart:hover.heart__paths { stroke: light-dark(var(--gray950), var(--gray50)); } .heart:focus-visible:has(.heart__paths--liked), .heart:hover:has(.heart__paths--liked) { background-color: light-dark(var(--red500-015), var(--red400-015)); }</style></head><body><divid="root"></div><scripttype="module">import e,{StrictMode as C,useEffect as T,useState as L,useRef as h}from"https://esm.sh/react";import{createRoot as $}from"https://esm.sh/react-dom/client";import{createScope as D,createTimeline as N,steps as u,svg as o,utils as S}from"https://esm.sh/animejs";import B from"https://esm.sh/clsx";$(document.getElementById("root")).render(e.createElement(C,null,e.createElement(R,null)));functionR({initialLiked:f=!1}){let s=h(null),l=h(null),[d,E]=L(f),t=500,r=5,p=4,y=`${r-p}${r+p}`,g=()=>{d||l.current?.methods.spin(),E(a=>!a)};return T(()=>{if(!s.current)return;let[a,i,n,c,k,M,Q]=S.$("path"),v="[data-target='outline']",_="[data-target='ray']",m=N({autoplay:!1});return m.add(c,{d:o.morphTo(k,0),ease:"inQuad",duration:t*.5,loop:1,alternate:!0}).add(M,{d:o.morphTo(Q,0),ease:"inQuad",duration:t*.5,loop:1,alternate:!0},0).add(a,{d:o.morphTo(i,0),ease:"inQuad",duration:t*.5},0).add(a,{opacity:0,ease:u(1),duration:t*.5},0).add(n,{opacity:1,ease:u(1),duration:t*.5},0).add(n,{d:o.morphTo(a,0),ease:"outQuad",duration:t*.5},t*.5).add(v,{transform:"translate(28, 0) scale(-1, 1)",ease:u(1),duration:t*.5},0).add(_,{strokeDashoffset:-r,ease:"inOutQuad",duration:t},0),l.current=D({root:s.current}).add("spin",()=>{m.restart()}),()=>{l.current?.revert(),m.revert()}},[]),e.createElement("div",{ref:s},e.createElement("button",{className:"heart",type:"button",title:d?"Unlike":"Like",onClick:g},e.createElement("svg",{className:"heart__svg",viewBox:"0 0 64 64"},e.createElement("g",{className:B("heart__paths",d&&"heart__paths--liked"),fill:"none",stroke:"currentcolor",strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:"2"},e.createElement("g",{transform:"translate(18, 19)"},e.createElement("path",{d:"M 7.5 3.5 Q 4 4 4 8",opacity:"1"}),e.createElement("path",{d:"M 23 3.5 Q 24.75 4.5 25.5 8",opacity:"0"}),e.createElement("path",{d:"M 5.5 3.5 Q 3 4 3 8",opacity:"0"}),e.createElement("g",{"data-target":"outline",transform:"translate(0, 0) scale(1, 1)"},e.createElement("path",{d:"M 14 2 C 2 -6,-10 10, 14 26"}),e.createElement("path",{d:"M 23 3.5 C 13.225 -6.497 -11 4.602 14 26",opacity:"0"}),e.createElement("path",{d:"M 14 2 C 26 -6, 38 10, 14 26"}),e.createElement("path",{d:"M 19 0.75 C 22.663 2.115 32.691 9.538 14 26",opacity:"0"}))),e.createElement("g",{strokeDasharray:y,strokeDashoffset:r,transform:"translate(32, 32)"},Array.from({length:8}).map((a,i)=>{let n=i*45,c=`rotate(${n}) translate(0, 15)`;return e.createElement("line",{key:n,"data-target":"ray",y2:r,transform:c})}))))))}</script></body></html>实现思路拆分
3D旋转心形按钮是怎么做出来的?
这一篇做一个3D旋转心形按钮:打开页面即可体验核心动效。整页靠 HTML、CSS 与 JavaScript 完成。
整体思路:三块内容分工
可以把成品页面想成三块积木拼在一起:
结构: .stage内 DOM 组件或控件。样式:背景、居中、过渡与关键帧动画。 脚本:事件监听与 classList/ 定时器驱动交互逻辑。
结构和样式分开写,改灯色或主题色只动 CSS 变量,不动 HTML。
配色与场景氛围
这一版用颜色与背景营造场景,而不只是装饰:
hue( --hue):223。gray50( --gray50):hsl(var(--hue) 10% 95%)。gray50-01( --gray50-01):hsl(var(--hue) 10% 95% / 0.1)。gray250( --gray250):hsl(var(--hue) 10% 75%)。gray650( --gray650):hsl(var(--hue) 10% 35%)。gray950( --gray950):hsl(var(--hue) 10% 5%)。gray950-01( --gray950-01):hsl(var(--hue) 10% 5% / 0.1)。red400( --red400):hsl(3 90% 60%)。red400-015( --red400-015):hsl(3 90% 60% / 0.15)。red500( --red500):hsl(3 90% 50%)。color-scheme:跟随系统浅色/深色,表单与滚动条风格一致。light-dark():前景、描边在深浅主题间自动切换。
让画面「活」起来
Grid place-items: center 让主体在视口正中。min() / clamp() / vmin 让组件随屏幕等比放大缩小。
动效方面,这一版启用了这些效果:
:has()把复选框状态传给灯体、路牌、马路或整页背景:hover提供悬停预览或高亮transition让状态切换更顺滑
源码获取

夜雨聆风