简介:这是一份基于CSS3实现的风水罗盘旋转特效资源,专门面向网页前端开发者和视觉动效爱好者,解决快速搭建富含传统文化韵味的动态元素需求。资源模拟真实罗盘的多层次结构,各环层以不同速度与方向旋转,形成鲜明层次感,适配传统文化主题页、风水行业官网或个性化挂件场景。包内共29个文件,含25张PNG素材、2张JPG背景、1个HTML示例页和1个CSS样式表,压缩后仅2.25MB,体积小巧便于下载复用,直接打开即可查看动画效果并自由修改样式。已有301人学习浏览,适合参考借鉴。仔细阅读源码可获得CSS3核心知识:transform的rotate()角度控制、@keyframes多段关键帧、transition过渡、:before/:after伪元素点缀、opacity与RGBA透明度混合、媒体查询适配移动端以及浏览器前缀兼容处理,还配有全套罗盘分层素材,降低从零绘制成本,是练习CSS3动效与快速落地的实用范例。
1. 为什么一个风水罗盘要用 CSS3 做旋转特效
在做测算类 H5、数字藏品展示页或者可视化大屏时,经常会遇到一类需求:页面上要有一个风水罗盘,能转、能拖、能指向某个方位。网上能找到的罗盘特效要么是整套 JS 插件,要么底图带水印,想改一个刻度颜色或者换一套配色,得先扒半天源码。CSS3 旋转特效的价值在于,旋转本身是纯样式层的事:transform: rotate()配合关键帧动画,不依赖图片素材、不引第三方库,刻度、圈层、指针全部自己生成,这就是「无水印版」的实质。这篇笔记把罗盘从静态圆盘到可拖拽、带惯性的完整落地过程拆开讲,适合要在活动页或数据可视化项目里快速塞一个转盘组件的开发者。
2. 画出能转的罗盘:CSS3 转换与最小动画结构
2.1 三层圆盘结构怎么搭:天池、二十四山与卦位圈
风水罗盘的结构很复杂,真正用于堪舆的盘面有十几层,但做视觉特效不需要还原那么细。常见做法是简化成三层:最外层是二十四山刻度,中间是八卦卦位,最内层是天池,也就是放指针的圆形区域。层级关系用绝对定位的嵌套div叠出来,每一层都是一个border-radius: 50%的圆。
<div class="compass" id="compass"> <div class="ring ring-outer"></div> <div class="ring ring-mid"></div> <div class="ring ring-core"></div> <div class="needle" id="needle"></div> </div>.compass { position: relative; width: 320px; height: 320px; margin: 40px auto; } .ring { position: absolute; border-radius: 50%; } .ring-outer { inset: 0; background: radial-gradient(circle, #f5e6c8 60%, #c9a96e 62%, #8b5e2a 100%); box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.3); } .ring-mid { inset: 12%; background: radial-gradient(circle, #fbf3e0 40%, #d4b58a 100%); } .ring-core { inset: 30%; background: radial-gradient(circle, #ffffff 50%, #e8d9bc 100%); border: 2px solid #8b5e2a; } .needle { position: absolute; left: 50%; top: 50%; width: 4px; height: 34%; background: linear-gradient(to bottom, #c0392b 50%, #2c3e50 50%); transform-origin: 50% 100%; transform: translate(-50%, -100%); border-radius: 2px; }这段代码里的关键点是inset属性。inset: 0让外层圆填满容器,inset: 12%让中间层缩小到容器的 76%,形成一个同心圆环效果。之所以用百分比而不是固定像素,是为了后续做缩放适配时不用重新算每一层的尺寸,改一个容器宽度,三层自动跟着变。
指针的transform-origin: 50% 100%值得单独说。默认的旋转中心是元素几何中心,但罗盘指针的物理转轴在底部圆心,所以要把旋转原点改到底边中点,再配合translate(-50%, -100%)把指针底部定位到容器的正中心。这样后续给指针加旋转角度时,它绕的就是罗盘圆心。
二十四山的刻度文字我一般不会手写在 HTML 里,而是用一小段 JavaScript 循环生成。常见做法是生成 24 个span,每个span的定位依赖两次rotate:先旋转到对应角度,再沿 Y 轴负方向推出去,最后反向旋转让文字保持正立。这段逻辑在避坑章节还会再遇到,这里先有一个印象。
2.2 让圆盘转起来:transform: rotate 与 @keyframes 的最小实现
罗盘转起来在 CSS3 里是最简单的一步,核心就两个东西:transform属性的rotate()函数和@keyframes关键帧。这里说的就是标题里那个「CSS3 转换」,也就是 transform。
@keyframes compass-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .ring-outer, .ring-mid { animation: compass-spin 20s linear infinite; }这段代码让外圈和中圈以 20 秒一圈的速度匀速旋转。参数上,linear是匀速,适合罗盘这种需要模拟真实转动的场景;如果换成ease-in-out,就会出现加速再减速的观感,看起来更像电机驱动而不是自由旋转。
这里有个容易忽略的点:两层的动画时长可以不一样。我给外层 20 秒,中层 30 秒,两个圈转速不同,视觉上更有层次感,也更接近真实罗盘各盘独立的意味。想要更明显的效果,可以外层 12 秒、内层 20 秒。但注意,转速差异不要太大,否则视觉上会乱。
还有一个更省事的选择,直接把动画加在.compass容器上,整盘一起转。这样做的优点是代码量最小,但缺点也很明显:中心指针如果也放在容器内,指针会跟着一起转,看起来就像罗盘失控。所以我一般只让刻度层转,天池层和指针保持静止,这才符合真实罗盘的使用直觉。
2.3 用 CSS 变量接管角度:动画变可控
@keyframes适合演示,但做交互时我们需要随时指定罗盘转到某一个角度,比如用户拖了一下,盘面要跟着手指走。这时关键帧动画反而碍事,因为它会持续覆盖transform值。常见做法是放弃animation,改用 CSS 变量 +transition。
.compass { --angle: 0deg; } .ring-outer, .ring-mid { transform: rotate(var(--angle)); transition: transform 0.1s linear; }const compass = document.getElementById('compass'); compass.style.setProperty('--angle', '45deg');设置--angle: 45deg之后,刻度层会直接旋转 45 度。transition: transform 0.1s linear的作用是让这个变化在 0.1 秒内平滑过渡,视觉上不是瞬跳而是连续转动。
CSS 变量在这里的价值不只是省代码。它把「旋转状态」从动画系统里剥离出来,变成了一个普通样式属性,JavaScript 只需要维护一个角度数值,然后不断setProperty即可。这样做的兼容性下限是 IE11 之后的全部浏览器,对移动端 WebView 也非常友好。
这里有一个需要提前说明的坑:transition只对连续的小角度变化友好。如果你把--angle从 359deg 直接改成 1deg,转盘会逆时针倒退一大圈。解决方法是让 JavaScript 维护一个持续累加的旋转总量,永远不取模,显示层再去计算实际方位。这个逻辑贯穿整个交互实现,下一章展开说。
3. 把旋转从演示变成可用交互:拖动、惯性回弹与角度计算
3.1 用 Pointer Events 和 atan2 计算拖拽角度
让罗盘跟随手指旋转,本质上是把指针在屏幕上的位置变化换算成角度变化。换算要用Math.atan2,它能根据 X、Y 坐标差返回从正 X 轴开始的角度,正好对应罗盘 0 度从正北开始的习惯。
const compassEl = document.getElementById('compass'); const outerRing = compassEl.querySelector('.ring-outer'); let totalAngle = 0; let startPointerAngle = 0; let startTotalAngle = 0; let isDragging = false; function getPointerAngle(e) { const rect = compassEl.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; return Math.atan2(e.clientY - cy, e.clientX - cx); } outerRing.addEventListener('pointerdown', (e) => { isDragging = true; startPointerAngle = getPointerAngle(e); startTotalAngle = totalAngle; outerRing.setPointerCapture(e.pointerId); }); outerRing.addEventListener('pointermove', (e) => { if (!isDragging) return; const currentAngle = getPointerAngle(e); let delta = currentAngle - startPointerAngle; totalAngle = startTotalAngle + delta; outerRing.style.setProperty('--angle', totalAngle + 'deg'); }); outerRing.addEventListener('pointerup', () => { isDragging = false; });atan2返回的是弧度制,范围在 -π 到 π 之间,换算成角度就是 -180 度到 180 度。这里的关键设计是不对totalAngle做任何取模运算。用户在同一个方向连续拖两圈,totalAngle会累加到 720 度以上,转盘始终沿一个方向累积旋转量,不会因为跨过 0 度边界出现倒退跳变。
另外一个细节是setPointerCapture。不调用它时,如果手指快速滑动到罗盘区域外,pointermove事件就不再触发,盘面会卡在半路。加上这句之后,指针事件被锁定到罗盘元素上,拖出边界依然能持续收到移动事件,这是做拖拽类交互的标配。
--angle的赋值带上了deg单位,这是 CSS 变量的一个常见坑:变量存的值是纯数字,但rotate()需要单位,所以必须在 JavaScript 拼接字符串时把单位一起处理好。
3.2 松手后的惯性:速度采样与 rAF 衰减
拖拽结束后立即停下,手感很生硬。真实罗盘松手后应该还有一段逐渐减速的滑行。惯性实现不复杂,但参数调起来很玄学,不同摩擦力系数手感差别很大。
let lastAngle = 0; let lastTime = 0; let velocity = 0; let rafId = null; outerRing.addEventListener('pointermove', (e) => { if (!isDragging) return; const now = performance.now(); const currentAngle = getPointerAngle(e); let delta = currentAngle - startPointerAngle; totalAngle = startTotalAngle + delta; // 计算瞬时速度:角度差 / 时间差 if (now - lastTime > 16) { velocity = (totalAngle - lastAngle) / (now - lastTime) * 16; lastAngle = totalAngle; lastTime = now; } outerRing.style.setProperty('--angle', totalAngle + 'deg'); }); function startInertia() { let v = velocity; function step() { v *= 0.95; totalAngle += v; if (Math.abs(v) < 0.01) { outerRing.style.setProperty('--angle', totalAngle + 'deg'); return; } outerRing.style.setProperty('--angle', totalAngle + 'deg'); rafId = requestAnimationFrame(step); } rafId = requestAnimationFrame(step); } outerRing.addEventListener('pointerup', () => { isDragging = false; startInertia(); });速度采样的窗口我用的是 16 毫秒,对应一帧的时间。每帧记录一次角度差值,除以帧间隔得到速度。衰减系数 0.95 意味着每帧速度保留 95%,按 60 帧每秒算,惯性大约持续 1 秒左右。想要更滑的手感,改成 0.97,持续 2 秒;想要干脆利落,改成 0.9,大约 0.4 秒就停。
衰减循环必须用requestAnimationFrame而不是setInterval。setInterval在浏览器标签页切到后台时会降频甚至暂停,但rAF会自动跟随屏幕刷新率,而且在页面不可见时暂停,不白耗性能。这个惯性的终止条件是速度绝对值小于 0.01 度每帧,这个阈值决定停止时是否会有一丁点「余震」,调太小会多转好几帧,调太大又显得刹车太急。
这里的血泪经验是:惯性循环里不要再叠加transition。上一章设置的transition: transform 0.1s linear在rAF逐帧赋值时会造成每帧都额外做一次 0.1 秒的补间,盘面转动看起来就会有黏滞感。做法是把过渡时长在拖拽模式下设为 0,惯性结束后再恢复。
3.3 指北回正与最短路径:取模后要算最近方向
很多场景下需要一个「归北」按钮,点一下罗盘自动回到正北。这里的问题不是转到 0 度,而是怎么转最省事。如果当前角度是 350 度,直接转到 0 度只要倒退 10 度,但如果程序简单地把目标设为 0 度,CSS transition 会逆时针转一大圈。
function returnToNorth() { const current = totalAngle % 360; let delta = -current; // 将 delta 限制到 [-180, 180] 区间,找最短路径 if (delta > 180) delta -= 360; if (delta < -180) delta += 360; const target = totalAngle + delta; outerRing.style.transition = 'transform 0.6s cubic-bezier(0.25, 0.1, 0.25, 1)'; outerRing.style.setProperty('--angle', target + 'deg'); totalAngle = target; setTimeout(() => { outerRing.style.transition = 'transform 0.1s linear'; }, 600); }核心逻辑就三行:先取模得到当前位置,然后取反得到「到 0 度的有符号角度差」,最后把差值收拢到正负 180 度范围。比如 350 度的罗盘,取模是 350,delta是 -350,小于 -180,所以加 360 变成 10 度,也就是顺时针再转 10 度归位。
归位动画的缓动函数我用的cubic-bezier(0.25, 0.1, 0.25, 1),这是 CSS 里最接近「先快后慢」的标准缓动。0.6 秒的时长和惯性滑行的节奏能衔接上,不会出现刚停稳又突然加速的突兀感。注意归位结束后要立刻把transition恢复成 0.1s,否则下一次手动拖拽时,拖拽的每一帧变化都会带着 0.6 秒的缓动尾巴,盘面会完全跟手。
3.4 让角度读数在元素可见时才展示:容器进入视口再触发动画
罗盘通常不是页面里唯一的内容,往往在首屏之外或者弹窗里。如果页面加载时就开始跑动画、刷读数,用户滚动到罗盘位置时动画可能已经跑完。CSS 的animation有animation-play-state可以配合IntersectionObserver控制,但用 CSS 变量加 rAF 的方案更直接:容器进入视口才启动惯性显示逻辑,离开视口就暂停。
const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { compassEl.dataset.visible = 'true'; } else { compassEl.dataset.visible = 'false'; } }); }, { threshold: 0.3 }); observer.observe(compassEl);阈值0.3表示罗盘有 30% 的面积进入视口才触发,比默认的 0 更合理。默认值在元素露出 1 像素时就触发,整个罗盘刚冒头一角就开始运转,看起来很怪。这里dataset.visible是一个开关,实际的度数显示逻辑在下一章进阶部分给出完整实现。只要记住一个原则:IntersectionObserver只负责通知状态变化,不要在里面跑重逻辑,真正的数字滚动和转盘动画放在独立的函数里,由状态触发。
4. 罗盘旋转特效避坑清单:合成层、兼容性和「无水印」的真相
这个标题里的「无水印版」,很多人的第一反应是去水印工具。但在开发语境下,说的是一套完全不依赖带水印素材的实现方案。前面几章用的所有刻度、圈层、指针都是代码生成的,没有一张外部图片,自然不会有水印问题。不过代码层面的坑一点都不少,下面这几条是我实际踩过的。
4.1 transform 祖先让 fixed 定位失效
现象:罗盘容器加了transform: rotate()之后,容器内部原本用position: fixed定位的提示气泡,不再相对于浏览器视口定位,而是相对于罗盘定位,位置完全错乱。
原因:CSS 规范规定,transform不为none的元素会创建一个新的包含块,内部fixed定位元素会以这个元素为定位基准。这不是浏览器 bug,而是标准行为。
解决:不要把气泡、弹层这类需要视口定位的元素放进旋转容器内。常见做法是让气泡元素与罗盘容器平级,通过 JavaScript 在pointermove中根据罗盘的getBoundingClientRect()实时计算气泡位置。如果必须放在内部,就把气泡改成position: absolute,手动计算相对罗盘的坐标。
4.2 旧 WebKit 内核下 rotate 不生效
现象:同一套代码,在最新 Chrome 上转盘正常旋转,但在某些旧版本 WebView 里,盘面直接静止不动,控制台也没有任何报错。
原因:这些 WebView 内核版本对transform属性要求带-webkit-前缀,不识别无前缀的标准写法。
解决:判断你的目标用户群体。如果罗盘只跑在 App 内嵌的现代 WebView 里,可以不管;如果要兼容老型号安卓设备的浏览器,就需要给旋转、过渡相关的属性补上前缀版本。手写前缀容易漏,建议构建时引入 Autoprefixer 这样的工具自动补齐。这个问题的隐蔽之处在于静态样式不报错,只会静默失效,排查时要优先检查是不是前缀问题。
4.3 刻度文字跟着圆盘反转
现象:外圈的二十四山文字,比如「壬、子、癸」,在盘面旋转后全部头朝下、脚朝上,完全没法读。
原因:刻度的span元素是旋转后推出去的,文字继承了旋转角度,自然跟着盘面翻转。
解决:每个刻度元素做三次变换,顺序不能乱。先旋转到对应角度,沿半径推出去,再反向旋转同样角度抵消翻转。
.scale-item { position: absolute; left: 50%; top: 50%; transform: rotate(var(--item-angle)) translateY(-120px) rotate(calc(-1 * var(--item-angle))); transform-origin: 0 0; }这里的核心是rotate(-angle)必须在translateY之后,而不是之前。CSS 变换的执行顺序是从右往左,先旋转到目标角度,推出去之后,再反向旋转让文字正立。顺序一换,文字的位置和朝向会完全错乱。--item-angle同样由 JavaScript 在生成刻度时赋值,例如第 3 个刻度就是15deg * 3 = 45deg。这个方案只适用于文字类刻度,如果是箭头或图标需要保持指向盘心,则不需要反向旋转这一步。
4.4 旋转后发虚、掉帧与掉帧的根源
现象:罗盘在电脑上转得很顺,放到手机上盘面边缘出现锯齿,旋转时有明显的掉帧感。
原因:三个子问题叠加。第一,旋转的圆盘如果用了底图,底图尺寸超过实际显示尺寸两倍,移动端 GPU 处理大纹理开销很大;第二,动画过程中浏览器没有把旋转层提升到独立合成层;第三,某些实现还在用left/top驱动动画,这会让浏览器每一帧都重新做布局。
解决:图片底图压缩到实际显示尺寸的 2 倍以内,不要用 2048px 大图显示 320px 圆盘;给旋转层加will-change: transform提示浏览器提前做合成层优化;任何旋转相关动画都一律使用transform,不要让布局属性参与逐帧变化。这里补充一个检查手段:Chrome 开发者工具的 Rendering 面板里打开 Layer Borders,旋转中的层如果显示了黄色边框,说明它已经处于独立合成层,否则就需要排查will-change是否生效。
4.5 角度回绕与 360 度跳变
现象:转盘转到 359 度附近时,如果这时候往回拖 2 度,盘面不是从 359 退到 357,而是猛地转了一整圈回到 357。
原因:JavaScript 维护角度时对totalAngle做了% 360取模。取模后的值在 359 和 0 之间跳变,而 CSS 的过渡动画不知道你想走哪条路,只能按差值最小或者按数值方向补间,结果就是失控。
解决:整个会话过程中,totalAngle保持原始累加值,不做任何取模。取模只发生在展示层,比如读取当前方位时。这个原则在 3.1 节的代码里已经贯彻,拖动、惯性、归北全部使用未取模的连续角度值,只有需要显示「当前指向 45 度」时才取一次模。养成这个习惯之后,所有角度跳变类问题会从根上消失。
5. 从演示到上线:scale 缩放方案与可见性触发的落地技巧
5.1 不同屏幕下罗盘不裂:scale 缩放方案与 rem 的边界
罗盘本身用固定像素320px定义,放进不同宽度的容器里就需要适配。我一般不用rem逐层换算,那个方案在同心圆结构里容易算错半径。更直接的做法是用transform: scale()做整体缩放,一套尺寸打天下。
function fitCompass() { const container = document.getElementById('compassContainer'); const compass = document.getElementById('compass'); const scale = Math.min(container.clientWidth / 320, container.clientHeight / 320); compass.style.transform = `scale(${scale})`; compass.style.transformOrigin = 'center center'; }scale只改变视觉尺寸,不改变元素在文档流里的占位空间,所以容器需要预留足够的空白。注意在容器尺寸变化时重新计算,调用时机放在window.resize和IntersectionObserver回调里都可以。还有细节:缩放中心默认是元素中心,如果罗盘摆放位置有偏移,记得同步调整transform-origin。
这里的边界是:scale缩放方案适合同比例圆盘,如果容器是长方形,两个方向缩放比例不一致,需要取较小值并居中,而不是强行撑满。
5.2 让角度读数在元素可见时再展示:IntersectionObserver 触发数字滚动
罗盘旁的度数读数,进入视口时应该有一个从当前值滚动到目标值的动画。结合 3.4 节的状态开关,在元素真正可见时才启动数字滚动,避免页面加载时无意义计算。
function animateNumber(el, target, duration = 800) { if (Number(el.dataset.current) === target) return; const start = performance.now(); const from = Number(el.dataset.current || 0); function tick(now) { const progress = Math.min((now - start) / duration, 1); const eased = 1 - Math.pow(1 - progress, 3); el.textContent = Math.round(from + (target - from) * eased); el.dataset.current = String(target); if (progress < 1) requestAnimationFrame(tick); } requestAnimationFrame(tick); }缓动用的1 - pow(1 - t, 3)是 easeOutCubic,数字变化先快后慢,视觉上比匀速滚动自然得多。dataset.current存的是上一次动画终点,避免重复触发时数字从头开始跳。整个方案的最后一环是:转盘的--angle改变时,取一次模得到当前方位角,调用animateNumber更新读数,整个过程不刷新页面、不重排布局,纯合成层动画跑满 60 帧没有压力。
我自己的习惯是,这类特效组件上线前一定会在 iOS 和安卓各找一台旧设备过一遍。血泪经验是:桌面端顺滑不代表移动端顺滑,will-change不是万能的,旧设备上如果持续掉帧,优先砍掉阴影和渐变层数而不是优化 JavaScript。以上就是整个罗盘旋转特效从零到上线的完整路径,核心代码都在上面了,照着跑一遍就能出效果,希望能帮到你。
本文还有配套的精品资源,点击获取