普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月3日首页

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

2026年3月3日 17:10

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

引言:当组件遇见 CSS

在现代前端开发中,组件化已成为构建用户界面的主流方式。我们将页面拆分为独立、可复用的组件,每个组件管理自己的 HTML、CSS 和 JavaScript。然而,CSS 的设计初衷是全局作用域的 —— 样式一旦定义,就会影响整个页面,这给组件化带来了严峻挑战。

试想一个多人协作的项目:A 同学写了一个按钮组件,类名为 .button;B 同学也写了一个按钮组件,同样用了 .button。当两个组件同时出现在页面上时,后加载的样式会覆盖前者,造成意料之外的 UI 错乱。如何让组件的样式“与世隔绝”,既不影响他人,也不被他人影响?本文将深入探讨 React 和 Vue 生态中三种主流的样式隔离方案:CSS Modulesstyled-componentsVue scoped。我们将通过实际代码,由浅入深地理解它们的原理与用法。

1. CSS 的“先天不足”与组件化的冲突

在传统网页开发中,我们通常这样写 CSS:

/* global.css */
.button {
  background-color: blue;
  color: white;
}

这个 .button 样式会作用于页面上所有带有 class="button" 的元素,无论它身处哪个组件。这种全局性在小型项目中或许无伤大雅,但在组件化架构下却成了灾难。

假设我们有 Button.jsxAnotherButton.jsx 两个组件,分别引入了各自的 CSS 文件:

/* Button.css */
.button { background: blue; }

/* AnotherButton.css */
.button { background: red; }
效果图

image.png

最终页面上两个按钮都会是红色,因为后引入的 AnotherButton.css 覆盖了前者的规则。这就是样式冲突的典型场景。

为了解决这一问题,社区发展出了多种作用域隔离方案,核心思想都是将样式“限定”在组件内部。下面我们分别看看 React 和 Vue 是如何做到的。

2. React 中的 CSS Modules

2.1 什么是 CSS Modules?

CSS Modules 是一种将 CSS 文件编译为局部作用域的技术。它并不是官方的 CSS 规范,而是通过构建工具(如 webpack)在编译时给类名自动添加唯一的哈希字符串,从而实现样式隔离。在 React 项目中,使用 Create React App 或 Vite 脚手架时,开箱即支持 CSS Modules。

2.2 基本用法

我们约定 CSS 文件命名为 *.module.css。在组件中像导入一个对象一样导入样式文件,然后通过对象的属性引用类名。

Button.module.css

.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
}

.txt {
  color: red;
  background-color: orange;
  font-size: 30px;
}

Button.jsx

import styles from './Button.module.css';
console.log(styles); // 输出:{ button: "Button_button__1a2b3c", txt: "Button_txt__4d5e6f" }

export default function Button() {
  return (
    <>
      <h1 className={styles.txt}>你好,世界!!!</h1>
      <button className={styles.button}>My Button</button>
    </>
  );
}
效果图

image.png

在浏览器中,最终渲染的 HTML 类似:

<h1 class="Button_txt__4d5e6f">你好,世界!!!</h1>
<button class="Button_button__1a2b3c">My Button</button>
打开控制台我们点击元素开可以看到每个元素都有唯一的id

image.png

可以看到,原始的类名 .button.txt 被转换成了带有组件名和哈希的唯一类名,从而避免了全局污染。

2.3 多人协作的保障

再来看另一个组件 AnotherButton,它也定义了同名的 .button 样式:

anotherButton.module.css

.button {
  background-color: red;
  color: black;
  padding: 10px 20px;
}

AnotherButton.jsx

import styles from './anotherButton.module.css';

export default function AnotherButton() {
  return <button className={styles.button}>My Another Button</button>;
}

两个组件的样式互不干扰,因为编译后的类名分别是 AnotherButton_button__xxxButton_button__xxx。这正是 CSS Modules 的魅力所在——让开发者无需担心类名冲突,专注于组件本身的样式。

2.4 原理浅析

CSS Modules 的原理并不复杂:在构建阶段,webpack 的 css-loader 会解析 *.module.css 文件,将每个类名映射为一个唯一的标识符(通常是 [文件名]_[类名]__[hash]),同时生成一个映射对象(即 styles)。在 JavaScript 中,我们通过这个映射对象来引用最终的类名,而 CSS 文件中的原始类名则被替换为哈希后的类名。这样,CSS 和 JS 就通过同一份映射关系保证了样式的私有性。

3. React 中的 styled-components

如果说 CSS Modules 是在编译时通过修改类名来实现隔离,那么 styled-components 则代表了另一种思潮:CSS-in-JS,即在 JavaScript 中编写 CSS,并利用 JavaScript 的作用域来实现样式隔离。

3.1 什么是 styled-components?

styled-components 是一个流行的 React 库,它允许你使用 ES6 的模板字符串定义样式组件,这些样式组件会自动生成一个唯一的类名,并将样式注入到 <head> 中。

3.2 基本用法

首先安装 styled-components:

npm install styled-components

然后在组件中创建样式化组件:

import styled from 'styled-components';

// 定义一个带样式的 button 组件
const Button = styled.button`
  background-color: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`;

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  );
}
效果图

image.png 渲染后的 HTML 如下(每个人的截图中的真实类名可能不同):

<button class="sc-axZvf jflFSQ">默认按钮</button>
<button class="sc-axZvf efDizw">主要按钮</button>
打开控制台点开控制台元素,我们同样可以看到每个元素都有唯一id

image.png 这里的 sc-axZvf 是组件标识前缀,同一组件生成的实例共享这个前缀,而 jflFSQ 和 efDizw 则是具体的样式类名,分别对应不同的样式规则(例如一个是默认样式,一个是 primary 样式)。所有样式都被动态地生成为 <style> 标签插入页面头部。

3.3 动态样式与 props

styled-components 的一大优势是支持基于 props 的动态样式。如上例所示,通过 props.primary 可以轻松改变背景色和文字颜色。这比传统 CSS 需要额外维护多个类名要直观得多。

3.4 原理浅析

styled-components 在运行时(runtime)工作:当组件渲染时,它会解析模板字符串中的样式规则,根据 props 计算出最终的 CSS 文本,然后生成一个唯一的类名(如 jflFSQ),并将 CSS 规则以 <style> 标签的形式插入到文档头部。值得注意的是,同一组件(如 Button)的所有实例会共享一个组件级标识(sc-axZvf),而具体样式类名则每个实例或每个变体可能不同。由于每个组件实例都可能生成不同的类名,样式天然是隔离的。同时,它还能自动处理浏览器前缀、关键帧动画等,为开发者提供了良好的体验。

4. Vue 中的 scoped 样式

Vue 作为另一大前端框架,其单文件组件(SFC)提供了内置的样式隔离方案——scoped 属性。

4.1 什么是 scoped?

在 Vue 的单文件组件中,可以在 <style> 标签上添加 scoped 属性,指示该样式只作用于当前组件。它的实现方式是为组件模板中的元素添加唯一的自定义属性(如 data-v-xxxxx),然后通过属性选择器来限制样式的生效范围。每个组件会生成一个唯一的哈希 ID,该组件内的所有元素都会被打上这个 ID 作为属性。

4.2 基本用法

App.vue

<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
  <div>
    <h1 class="txt">Hello world in App</h1>
    <h2 class="txt2">一点点</h2>
    <HelloWorld />
  </div>
</template>

<style scoped>
.txt {
  color: red;
}
.txt2 {
  color: green;
}
</style>

HelloWorld.vue

<template>
  <div>
    <h1 class="txt">你好,世界!!!</h1>
    <h2 class="txt2">一点点</h2>
  </div>
</template>

<style scoped>
.txt {
  color: blue;
}
.txt2 {
  color: orange;
}
</style>
效果图

image.png

4.3 渲染结果与原理

编译后,Vue 会为每个组件生成一个唯一的哈希 ID。假设 App 组件的 ID 为 data-v-7a7a37b1,HelloWorld 组件的 ID 为 data-v-e17ea971。最终渲染的 HTML 结构如下(来自实际截图):

html

<div data-v-7a7a37b1>
  <h1 data-v-7a7a37b1 class="txt">Hello world in App</h1>
  <h2 data-v-7a7a37b1 class="txt2">一点点</h2>
</div>

<div data-v-e17ea971 data-v-7a7a37b1>
  <h1 data-v-e17ea971 class="txt">你好,世界!!!</h1>
  <h2 data-v-e17ea971 class="txt2">一点点</h2>
</div>

仔细观察可以发现:

  • App 组件内的所有元素(包括根 div)都带有自己的 ID data-v-7a7a37b1
  • HelloWorld 组件内的所有元素(包括其根 div)都带有自己的 ID data-v-e17ea971特别地,HelloWorld 的根元素上还额外附加了父组件 App 的 ID data-v-7a7a37b1。这是 Vue 故意设计的,目的是让父组件的样式可以通过属性选择器(如 .txt[data-v-7a7a37b1])作用于子组件的根元素,从而实现父组件对子组件根节点的样式控制(如果父组件样式选择器匹配的话)。

对应的 CSS 会被编译为:

css

.txt[data-v-7a7a37b1] { color: red; }
.txt2[data-v-7a7a37b1] { color: green; }
.txt[data-v-e17ea971] { color: blue; }
.txt2[data-v-e17ea971] { color: orange; }

由于属性选择器的存在,每个组件的样式只作用于带有对应属性的元素,实现了完美的样式隔离。同时,子组件根元素拥有双重属性,使得父组件的样式能够有选择地影响子组件的最外层,保持了样式的可控性。

打开控制台元素,我们就可以看到

image.png

4.4 与 CSS Modules 的对比

Vue 的 scoped 与 React 的 CSS Modules 思路相似,都是通过给选择器附加唯一标识来实现作用域。区别在于:

  • CSS Modules 修改了类名本身,而 Vue 保留了原始类名,额外添加了属性选择器。
  • Vue 的 scoped 无需导入对象,直接在模板中使用原始类名,可读性更好。
  • CSS Modules 需要显式引用 styles 对象,略显繁琐,但胜在灵活(比如可以组合多个类名)。

4.5 原理浅析

Vue 在编译单文件组件时,会为每个组件生成一个唯一的哈希 ID。然后:

  1. 将模板中的所有元素加上该 ID 作为属性(根元素额外加上父组件的 ID,如果存在父组件)。
  2. 将 <style scoped> 中的每条 CSS 规则都加上对应的属性选择器。
  3. 最终生成带作用域的 CSS。

整个过程在构建阶段完成,没有运行时开销,性能极佳。

5. 对比与总结

方案 框架 实现原理 优点 缺点
CSS Modules React / Vue 编译时修改类名,生成哈希映射 静态样式,简单可靠;可与预处理器结合 类名需要引用,模板稍显啰嗦
styled-components React 运行时生成唯一类名,注入 <style> 动态样式能力强;完全组件化;支持 props 运行时开销;包体积较大;调试稍难
Vue scoped Vue 编译时添加唯一属性,属性选择器限制 语法简洁;无运行时开销;保留原始类名 仅适用于 Vue;深度选择器需特殊处理

如何选择?

  • 如果你的项目是 React,且偏好“传统”的 CSS 写法,CSS Modules 是最佳选择,它简单、高效,与设计工具(如 Figma)配合良好。
  • 如果你追求极致的动态样式和组件封装,或者希望将样式也作为组件逻辑的一部分,styled-components 能带来流畅的开发体验。
  • 对于 Vue 项目,scoped 是官方推荐的内置方案,开箱即用,足够满足绝大多数场景。

当然,这些方案并非互斥。在大型项目中,你可能会组合使用它们:用 CSS Modules 处理全局样式库,用 styled-components 处理高频复用的动态组件。重要的是理解每种方案的原理,以便在合适的场景做出正确的选择。

结语

从 CSS 的全局困境到组件样式的精细隔离,前端社区给出了多种优雅的解决方案。无论是 React 的 CSS Modules 和 styled-components,还是 Vue 的 scoped,它们都体现了“关注点分离”到“组件内聚”的思想演进。希望本文能帮助你更好地掌握这些工具,在项目中写出健壮、可维护的样式代码。如果你有更多关于样式隔离的思考或实践,欢迎在评论区交流讨论!

🚀 别再乱写 16px 了!CSS 单位体系已经进入“计算时代”,真正的响应式布局

作者 Sailing
2026年3月3日 13:50

如果你的项目里还充满 px + media query —— 那说明你在“维护样式”,而不是“设计系统”。

现代 CSS 的能力,已经远远超出“写几个断点”那么简单。

今天我们从工程视角,把 单位体系 + 计算体系 一次讲透。

u=3751345329,3281070072&fm=253&fmt=auto&app=120&f=JPEG.webp

CSS 单位体系:本质不是长度,而是“依赖关系”

CSS 单位可以分成三类:

  1. 绝对单位
  2. 相对单位
  3. 视口单位

单位能力对比表

单位 类型 依赖对象 系统角色 推荐级别 典型场景
px 绝对 精准控制 ✅ 必须存在 图标 / 1px 边框
em 相对 父级字体 局部缩放 ⚠️ 慎用 组件内部
rem 相对 根字体 全局缩放 ✅✅ 核心 系统布局
vw 视口 视口宽度 宽度适配 响应布局
vh 视口 视口高度 高度控制 全屏模块
vmin 视口 较小值 动态比例 特殊适配
vmax 视口 较大值 极端场景 特殊适配
dvw 动态视口 实际可视宽 移动端修正 🚀 H5
dvh 动态视口 实际可视高 移动端高度修正 🚀 APP/H5

重点认知

单位不是写数值
单位是在声明“依赖谁”

  • px → 不依赖任何人(绝对控制)
  • rem → 依赖根节点(系统级缩放)
  • vw → 依赖视口(设备相关)
  • dvh → 依赖真实可视区域(移动端优化)

如果你做企业级系统:

推荐核心组合

rem + clamp + min/max + dvh

其他单位只是辅助。我们往下看!

WX20240522-152437@2x.png

函数才是现代 CSS 的“计算引擎”

它们不是单位,但它们决定单位如何协作。

函数 作用 优势 工程价值
calc() 计算 混合单位运算 结构关系表达
min() 上限控制 自动封顶 替代 max-width
max() 下限控制 自动兜底 保证可读性
clamp() 区间控制 响应式缩放 替代 media query

核心函数深度解析(实战理解)

现在我们进入“可落地”部分。

🔥 1. rem —— 系统级缩放开关

原理

1rem = htmlfont-size

推荐做法

html {
  font-size: 16px;
}

组件写法:

.box {
  padding: 2rem;
}

为什么它是系统基石?

如果未来:

  • 设计改缩放比例
  • 项目整体要变大

你只需要改:

html { font-size: 18px }

🔥 全站自动缩放。
🔥 无需改任何组件。

这才叫“系统”。

🔥 2. clamp() —— 响应式终极武器

这是现代 CSS 的核心。

语法

clamp(min, preferred, max)

实战:

h1 {
  font-size: clamp(20px, 4vw, 48px);
}

效果:小屏不小于 20px,大屏不超过 48px,中间随视口自动变化。

工程价值

❌ 不需要维护设备列表 media query (@media (max-width: 768px))
❌ 不需要写多个断点
❌ 不需要拆分 PC / Mobile

✅ 一行表达“数学区间关系”

这不是技巧,是范式升级。

🔥 3. min() —— 自动封顶

传统写法:

width: 90%;
max-width: 1200px;

现代写法:

width: min(1200px, 90%);

区别?

  • 数学表达
  • 单行逻辑
  • 结构更清晰

表达的是:

宽度 = 两者中更小的那个

这才叫可维护。

🔥 4. max() —— 自动兜底

.box {
  padding: max(16px, 2vw);
}

保证:

  • 最小 16px
  • 又允许动态放大

用于保证阅读体验、可触控面积。

🔥 5. calc() —— 混合运算核心

.sidebar {
  width: 300px;
}

.content {
  width: calc(100% - 300px);
}

能力:

  • 支持加减乘除
  • 支持单位混合
  • 表达结构关系

它表达的是:

主体宽度 = 容器宽度 - 侧栏宽度

这叫“布局计算”,而不是“写死数值”。

39eb0728a2c0407faacb769863300d59.gif

视口单位:vw / vh / vmin / vmax

它们的共同点只有一个:依赖设备视口

vw

1vw = 视口宽度的 1%
.box {
  width: 50vw;
}

用于横向比例布局。

vh

1vh = 视口高度的 1%
.hero {
  height: 100vh;
}

用于全屏模块。

⚠️ 移动端慎用(dvh 更稳定)。

vmin

vmin = min(vw, vh)

始终基于短边。

.circle {
  width: 50vmin;
  height: 50vmin;
}

横竖屏切换比例稳定。

vmax

vmax = max(vw, vh)

始终基于长边。

.bg {
  font-size: 20vmax;
}

适合视觉冲击型页面。

image(2).png

移动端必须升级:dvh、dvw

移动端地址栏会动态伸缩,100vh ≠ 实际可视高度。

解决方案:

height: 100dvh;

优势:

  • 永远是真实可视区域
  • 不会因浏览器 UI 变化跳动
  • H5 / WebApp 必备

企业级布局推荐方案

如果你做后台系统 / 复杂管理台:

❌ 过时写法

  • 大量 @media
  • 到处 max-width
  • 写死 16px / 20px
  • 用断点区分设备

那是样式堆叠时代。

✅ 现代写法

固定系统:

px + min + max + clamp

弹性系统:

rem + clamp + vw + dvh

目标只有一个:写“关系”,而不是写“数值”。

总结:真正的思维升级

如果你的项目:

  • 还在到处写 16px
  • 还在疯狂加 media query
  • 还在拆 PC / 移动端
@media (max-width: 768px)

你是在:手动划分设备,维护设备列表,增加未来维护成本。

而当你写:

font-size: clamp(16px, 2vw, 24px);

你是在:写数学区间,写系统规则,让浏览器自己计算

你认为呢,希望这篇文章对你有所帮助、有所借鉴,欢迎在评论区随时沟通。

昨天以前首页

游戏官网前端工具库:海内外案例解析

2026年3月2日 17:43

在游戏行业竞争日趋激烈的当下,游戏官网早已不再是单纯的信息展示页,而是承载品牌叙事、玩家沉浸体验、内容传播的核心载体。本篇文章,基于海内外游戏官网案例(GTA VI、崩:星穹铁道、Elden Ring 等等),从 动画交互实用工具性能优化UI 组件 等多个维度,对各类工具进行梳理和分析,希望能为游戏官网开发者提供参考思路。

一、动画与交互

动画与交互是游戏官网吸引用户的核心要素,涵盖滚动控制、动态效果、手势响应等工具。

1.1 滚动与视差

通过平滑滚动与多层背景差速移动,营造深度感和电影般的叙事节奏。

💠 Lenis

Lenis 是一款 轻量级(4.7kB)的平滑滚动库。保留滚动条、不破坏原生事件,却能给出 惯性阻尼自定义缓动。可以与主流动画库如 GSAP 集成,实现基于滚动进度的动画。非常适合用于追求流畅体验的网站。

地址:github.com/darkroomeng…

案例:劍與遠征:啟程

1.gif

ouseMultiplier: .7:鼠标滚动倍率(0.7 倍原生速度),较慢的滚动速度能让玩家更从容地浏览角色立绘和剧情介绍。smooth: !0 开启平滑滚动,配合 GSAP 控制的渐显动画,增强视觉连贯性。将 UI状态变化(导航固定、右侧悬浮栏显示)放在滚动事件监听器里。

const lenis = new Lenis({
  mouseMultiplier: 0.7,
  smooth: true,
  smoothTouch: false
})

lenis.on('scroll', (e) => {
  document.querySelector('header').classList.toggle('Fixed', e.scroll > 200)
  document.querySelector('.back-top').classList.toggle('show', e.scroll > window.innerHeight / 2)
  if (window.ScrollTrigger) ScrollTrigger.update()
})

function raf(time) {
  lenis.raf(time)
  requestAnimationFrame(raf)
}
requestAnimationFrame(raf)

💠 fullPage

fullPage.js 专门用于快速创建全屏滚动网站(也称为单页滚动网站)。它将浏览器视口分割成多个全屏大小的部分,并通过平滑的垂直或水平滚动在它们之间进行导航。能提供一种沉浸式、滚动翻阅般的浏览体验,适合需要以 “叙事节奏” 引导用户探索的场景。

地址:alvarotrigo.com/fullPage/

案例:最终幻想14

2.gif

平衡多端体验,responsiveWidth: 750 手机/平板直接原生滑动,优先保证浏览流畅性。afterLoad 事件自动播放视频 & 音轨,onLeave 事件暂停节省流量和优化性能。scrollOverflow 对于内容超高的区域允许单屏内部再滚动, 解决长内容的展示问题。

new fullpage('#fullpages', {
  scrollOverflowReset: true,
  scrollOverflow: true,
  scrollBar: false,
  ...
  resize: true,
  responsiveWidth: 750,
  afterLoad: this.afterLoad,
  onLeave: this.onLeave,
});

💠 Locomotive Scroll

相较于 Lenis 的 物理仿真 或 fullPage 的 全屏分段,Locomotive Scroll 的核心优势在于对 微视差 的精准控制,通过精确监测滚动位置并驱动元素应用视差、平滑过渡等效果,配合 GPU 硬件加速确保流畅运行,尤其适合用于追求强视觉冲击力的官网场景。

地址:locomotivemtl.github.io/locomotive-…

案例:Wizardry Variants Daphne

3.gif

协同使用 Locomotive Scroll(负责平滑滚动)和 GSAP ScrollTrigger(负责基于滚动的动画触发)来创建复杂的交互效果。smartphone 和 tablet 的配置确保在各种移动设备上都能保持平滑滚动体验。lerp: .1 让滚动带有轻微阻尼感,配合魔法场景强化世界观的沉浸感,touchMultiplier: 2 则优化移动端体验,触摸滚动灵敏度倍增,让手机玩家滑动时能快速切换场景。

gsap.registerPlugin(ScrollTrigger);
locomotiveScroll = new LocomotiveScroll({
  el: document.querySelector('.js-root'), 
  smooth: true,                          
  smartphone: { smooth: true },        
  table: { smooth: true },            
  touchMultiplier: 2,                
  lerp: 0.1                           
});

1.2 过渡与动效

为页面元素的状态变化添加流畅的过渡效果,优化官网内容切换的节奏感与视觉连贯性。

💠 Animate.css

Animate.css 是一款轻量级纯 CSS 动画库,提供了多达 60 多种 预设的动画效果(如淡入淡出、滑动、弹跳、旋转等),覆盖页面元素加载、交互反馈、场景过渡等需求。只需为 HTML 元素添加相应的 CSS 类名(例如 animate__animated 基础类和 animate__fadeIn 淡入)即可快速添加动画。

地址:animate.style/

案例:重返未来:1999

5.gif

世界板块 视觉内容(游戏图片)animate__fadeInLeft 从左侧淡入,右侧文字描述 animate__fadeInUp 随后浮现。影音板块 视频/图集/音乐 animate__fadeInUp 统一从下方淡入。通过 animate__delay-* 实现 阶梯式延迟(0s, 1s, 2s),形成依次入场的效果。

<!-- 世界 -->
<div class="swiper-slide pc backstory" data-mouse="small" id="slide4">
    <div class="backstory-left animate__animated animate__fadeInLeft" data-mouse="small">
        <div class="swiper mySwiper backstory-border" id="pcbackstory" data-mouse="small">
            <!-- 视觉内容(游戏图片) -->
        </div>
    </div>
    <div class="backstory-right" data-mouse="small">
        <div class="backstory-right-str animate__animated animate__fadeInUp" data-mouse="small" id="backstoryStr">
            1999年最后一天,“暴雨”降临世界:<br>
            地面无故溢起积水,你的指尖碰到飞升的雨滴—— 一场“暴雨”在向天空倾泻。<br>
            行人和墙壁在雨中剥落溶解,世界似乎来到一个崭新的旧时代。<br>
            而除了你之外的所有人,都在“暴雨”侵蚀后不知所踪。<br>
            1999年的秘密,藏在层层雨幕的背后,藏在1999年最后一天。
        </div>
    </div>
</div>

<!-- 影音 -->
<div class="swiper-slide pc gallery" data-mouse="small" id="slide5">
    <div class="gallery-top" data-mouse="small">
        <div class="gallery-top-1 animate__animated animate__fadeInUp" data-mouse="small" onclick="openVideomask()">
            <!--游戏视频-->
        </div>
        <div class="gallery-top-2 animate__animated animate__fadeInUp animate__delay-1.5s" data-mouse="small"
            onclick="openPapermask()"> 
            <!--游戏图集-->
        </div>
        <div class="gallery-top-3 animate__animated animate__fadeInUp animate__delay-2s" data-mouse="small"
            onclick="openMusicmask()"> 
            <!--游戏音乐-->
        </div>
    </div>
</div>

💠 AOS

AOS(Animate On Scroll)是一款专注于 “滚动触发动画” 的轻量级 JavaScript 库,核心功能是监测页面元素滚动至视口范围时,自动触发淡入、缩放、位移、旋转等预定义动效。既以轻量化特性(核心体积仅 15KB)避免性能损耗,又强化了内容浏览的叙事层次感。

地址:michalsnik.github.io/aos/

案例:Counter-Strike 2

6.gif

只需通过简单的 HTML 属性进行配置,即可快速实现动画。例如作为页面最顶部的核心宣传语,长时长(data-aos-duration="2500")的淡入动画(data-aos="fade-up")让文字缓慢浮现,比其他元素稍晚出现(data-aos-delay="600"),创造一种错落有致的入场节奏。

<div class="aos-init aos-animate" data-aos="fade-up" data-aos-delay="600" data-aos-duration="2500">
    “反恐精英历史上最大的技术飞跃。”
</div>
<div class="aos-init aos-animate" data-aos="fade-in" data-aos-delay="500" data-aos-duration="1000">
了解更多
</div>
<div class="aos-init aos-animate" data-aos="fade-right" data-aos-delay="100" data-aos-duration="1500">
反恐精英 预告片
</div>

1.3 动画引擎

当涉及复杂动画时,专业引擎可实现复杂的时间线动画、物理动效与骨骼动画。

💠 Lottie

Lottie.js 是 Airbnb 开源的一个轻量级动画渲染库,核心功能是将 After Effects 导出的 Lottie 格式(JSON 文件)动画在网页端直接渲染,能流畅实现骨骼动画、粒子特效、路径动画等复杂效果,让设计师的创意能还原为前端可交互的沉浸式动画。

地址:github.com/LottieFiles…

案例:逆水寒

4.gif

将设计师在 AE 中制作的图标动画还原到网页,使用 renderer: "svg" SVG 渲染可保证在任何屏幕尺寸下不失真。鼠标悬停触发动画播放,当鼠标移开时,动画立即停止并跳回第一帧,这确保了下次悬停时动画总是从开头播放。

createLottieAnim = function(e) {
  var n = window.lottie.loadAnimation((0, o.default)({
    renderer: "svg", 
    loop: true, 
    autoplay: true
  }, e, {
    path: "https://n.res.netease.com/pc/zt/20210308165742/" + e.path
  }));

  return "hover" === e.event && $(e.hoverElem || e.container).hover(
    function() { n.play() }, 
    function() { n.stop() }  
  ),
  n;
}

💠 GSAP

GSAP(GreenSock Animation Platform)是一款功能强大、性能卓越的专业动画引擎,它能够高效地创建从简单过渡到复杂序列的各类动画,支持驱动 DOM 元素、SVG、Canvas、3D 模型(如 Three.js)、骨骼动画(如 Spine)等多类型载体的动画。

地址:gsap.com/

案例:Honkai: Star Rail – May this journey lead us starward

7.gif

GSAP 在案例中被用来驱动 Three.js 3D 对象 getObjectByName("s_line") 的属性,创建出流畅和富有质感的交互效果。不仅可以实现常规的缩放 scale、旋转 rotation 动画,还可以变化着色器的 uniforms 变量,实现亮度增加的材质动画。

this.focusOver = function(e) {
    var t = e.getObjectByName("s_line");
    gsap.to(t.scale, .8, {
        x: .9 * t.userData.initScl.x,
        y: .9 * t.userData.initScl.y,
        ease: o.Back.easeInOut,
        overwrite: 1
    }),
    gsap.fromTo(t.rotation, .8, {
        z: t.userData.initRot.z
    }, {
        z: t.userData.initRot.z + 1,
        ease: o.Back.easeOut,
        overwrite: 1
    }),
    gsap.to(t.material.uniforms.brightness, .2, {
        value: .2,
        overwrite: 1
    })
}

💠 Three.js

Three.js 是基于 WebGL 的 Web 3D 渲染引擎,提供了简洁易用的 API,使开发者无需掌握深厚的图形学知识,就能在网页浏览器中高效创建和展示交互式的 3D 场景、动画和模型。内置了灯光、阴影、材质、几何体、相机控制等丰富的 3D 图形功能,并支持导入多种格式的 3D 模型。

地址:threejs.org/

案例:第五人格

10.gif

案例展示了经典和实用的 3D 角色展示方案,添加环境光 AmbientLight 提供基础亮度,平行光 DirectionalLight 模拟主光源。加载GLTF格式的3D模型,并通过 AnimationMixerclipAction 播放模型自带的动画,同时支持玩家通过拖拽来旋转模型。

// 环境光
var i = new THREE.AmbientLight(16777215, 1);
// 平行光
var n = new THREE.DirectionalLight(16777215, 1);

// 初始化GLTF模型加载器(游戏常用3D格式,支持模型+动画)
var c = new THREE.GLTFLoader; 
var h = e; // e为模型文件路径(如“survivor_doctor.glb”,角色的3D模型)

c.load(h, function(e) { 
  u = e.scene; 
  // 初始化动画混合器(控制角色动画播放)
  var o = e.animations; 
  m = new THREE.AnimationMixer(u);
  var i = m.clipAction(o[0]); 
  i.play();
  s.add(u); 
})

// 鼠标移动:计算偏移,更新模型旋转
window.addEventListener("mousemove", function(e) {
  v.ex = e.pageX; 
  var o = v.ex - v.sx; 
  u.rotation.y = v.rt + .01 * o; // 更新模型旋转角(0.01为旋转速度,避免过快)
});

💠 PixiJS

PIXI.js 是一款开源、高性能的 2D 渲染引擎,核心优势在于基于 WebGL 硬件加速的高效绘制能力,能以极低的性能损耗渲染大量 2D 元素,同时兼容 Canvas 作为降级方案。支持精灵 Sheet 优化资源加载、骨骼动画驱动角色动作、鼠标 / 触摸交互检测(如点击、拖拽、碰撞)等功能。

地址:pixijs.com/

案例:Crystal of Atlan

案例展示了基于 PixiJS 实现游戏角色的展示,利用 Assets 系统统一预加载、缓存角色资源,提升加载速度。静态角色用轻量 Sprite 节省性能,动态角色用 Spine 骨骼动画增强表现力。并通过 getLocalBounds() 获取骨骼动画的边界框,结合自定义的偏移量来精确调整位置。

11.gif

// 注册角色资源到Pixi的资源管理器
Ni(IK).call(IK, (function(t) {
  t.characterImgUrl && e.Assets.add(t.pinyin, t.characterImgUrl),
  t.spineUrl && e.Assets.add(t.pinyin, t.spineUrl)
}));

// 后台预加载所有注册的角色资源
window.PIXI.Assets.backgroundLoad(
  Vr(IK).call(IK, (function(e) { return e.pinyin })) 
);

Ni(IK).call(IK, (function(t, n) {
  e.Assets.load(t.pinyin).then((function(r) {
    ...
    // 静态角色图片:创建Pixi精灵(Sprite)
    if (t.characterImgUrl) {
      CK[n] = new e.Sprite(r); 
    } 
    // 动态骨骼动画:创建Spine动画对象
    else {
      var a = new e.spine.Spine(r.spineData); 
      a.skeleton.setToSetupPose(); 
      a.update(0); 
      ...
      // 计算骨骼动画的本地边界(用于定位调整)
      var s, u, l = a.getLocalBounds();
      
      // 调整骨骼动画位置(基于边界计算,确保角色锚点正确)
      a.position.set(
        -l.x + (t.skelOption.characteX || 0), // X轴偏移(可自定义微调)
        -l.y + (t.skelOption.characterY || 0)  // Y轴偏移
      );
      ...
    }
  }));
}));

1.4 轮播与滑动

以可交互的滑动组件高效展示预告视频、角色立绘与新闻资讯等内容。

💠 Swiper

Swiper 是一款滑动交互组件库,它提供丰富的配置项(如自动播放、自定义分页器、过渡动画时长),内置淡入淡出、滑动、 cube 3D 等过渡效果,使开发者能够轻松构建响应式的轮播图、画廊、内容滑块及选项卡切换等交互组件,同时兼容框架如 React、Vue 等。

地址:swiperjs.com/

案例:哈利波特:魔法觉醒

8.gif

案例通过 coverflow 3D 效果,结合 centeredSlides(当前卡片居中放大)和 slidesPerView(自动适配数量),打造了一个3D立体翻转的轮播图。其中 rotate 旋转角度创造出卡牌翻转的视觉效果;stretch 拉伸强度影响卡牌之间的间距和变形;depth 深度控制前后堆叠的层次感。

this.featureSwiper = new Swiper('.feature_container', {
    effect: 'coverflow',
    centeredSlides: true,
    slidesPerView: 'auto',
    coverflow: {
        rotate: 50,
        depth: 100,
        stretch: 120,
        slideShadows: false,
    },
})

💠 Flickity

Flickity 专注于创建 流畅的触摸滑动组件(如轮播图、内容滑块),主打模拟真实物理惯性的滑动体验,核心特色是支持 非固定帧自由拖拽(freeScroll 模式)—— 用户可随意滑动浏览内容,无需像传统轮播那样强制切换完整幻灯片,配合自然的惯性衰减动效,还原 “随手翻阅卡片” 的真实触感。

地址:flickity.metafizzy.co/

案例:Baldur's Gate 3

9.gif

案例使用 Flickity 创建了一个用于展示奖项荣誉的自动轮播组件,启用 draggable 意味着在移动设备上用户可以自然地进行触控滑动,而在桌面端也可能支持鼠标拖拽。结合 wrapAround 实现的无限循环,无限循环让玩家可反复浏览,也避免 “滑到尽头后无法继续” 的生硬体验。

const slider = new window.Flickity(document.getElementById("awards"),{
adaptiveHeight: false,
    cellAlign: "left",
    wrapAround: true,
    draggable: true,
    autoPlay: true
})

二、工具类集成

工具库能快速为官网添加复杂功能,降低开发成本,覆盖社媒、支付、安全等全场景需求。

2.1 社交与分享

社交平台组件方便玩家分享内容并展示社区动态,扩大传播范围。

💠 Facebook Widgets

Facebook Widgets 公共主页插件是由 Facebook 官方提供 的工具,允许将 Facebook 公共主页直接嵌入到网站上。用户无需离开当前网站即可查看主页的封面、帖子流、活动信息,并能直接进行 点赞、关注、分享 等互动操作,有效帮助提升粉丝数量和内容曝光度,加强与社交媒体的联动。

地址:developers.facebook.com/docs/plugin…

案例:浮生憶玲瓏

13.gif

案例的核心配置如下:

  • data-tabs="timeline":设置显示 “时间线”(主页动态流)
  • data-adapt-container-width:在手机等窄屏设备上自动缩小宽度(保持比例),避免插件溢出容器导致的布局错乱,确保移动端用户也能正常查看。
  • data-small-header="false":显示完整头部(含主页名称 “浮生憶玲瓏”、粉丝数)。
  • data-hide-cover="false":展示 Facebook 主页的封面图(通常是游戏宣传图、角色插画)。
  • data-show-facepile="true":显示互动粉丝的头像(如点赞、评论过的玩家)。
<div class="facebook_box">
    <div class="facebook">
        <div class="fb-page" 
        data-href="https://www.facebook.com/fsyll.tw" 
        data-tabs="timeline" 
        data-width="500" 
        data-height="270" 
        data-small-header="false" 
        data-adapt-container-width="true" 
        data-hide-cover="false" 
        data-show-facepile="true"
        >
        <blockquote cite="https://www.facebook.com/fsyll.tw" class="fb-xfbml-parse-ignore">
        <a href="https://www.facebook.com/fsyll.tw">浮生憶玲瓏</a>
       </blockquote>
    </div>
    </div>
</div>

2.2 媒体播放

提供跨浏览器的视频播放解决方案,展示游戏预告片、实机演示和直播流等。

💠 YouTube 播放器

以下是嵌入 YouTube 视频的两种主要方式:

<iframe> 嵌入

直接 <iframe> 嵌入是 最简单、最快捷 的方法,只需从 YouTube 分享界面复制现成的 <iframe> 代码并粘贴到 HTML 中即可。这种方法无需编写 JavaScript 代码,适合需要快速、简单地在网页上静态展示视频的场景,但交互控制有限,仅能满足基础播放需求。

地址:www.youtube.com

案例:ELDEN RING NIGHTREIGN

15.gif

案例中的核心参数如下:

  • autoplay=1&mute=1&loop=1&playlist=同一ID:自动播放且静音,现代浏览器默认禁止有声自动播放。loop 配合 playlist 指定同一视频 ID 确保视频持续循环。
  • rel=0:隐藏相关视频推荐,避免用户被其他视频分流。
  • hd=1:优先加载 720p 或更高清 画质,确保画面质感。
  • loading="lazy":首屏懒加载,避免因视频资源过大导致页面卡顿。
  • allow="accelerometer; autoplay; ...; picture-in-picture":声明允许的浏览器功能,支持移动端陀螺仪(增强横屏体验)、自动播放、画中画等,适配多设备交互。
<iframe id="ytplayer" frameBorder="0" allowfullscreen="" loading="lazy" 
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" 
title="ELDEN RING NIGHTREIGN" width="100%" height="100%" 
src="https://www.youtube.com/embed/UABQQ5TyGNU?autoplay=1&amp;loop=1&amp;playlist=UABQQ5TyGNU&amp;mute=1&amp;hd=1&amp;controls=0&amp;rel=0&amp;fs=0&amp;enablejsapi=1&amp;origin=https%3A%2F%2Fbandainamcoent.asia&amp;widgetid=1">
</iframe>

Player API

加载 API 脚本并通过 JavaScript 控制视频,可以实现复杂的交互,例如控制视频的播放、暂停、跳转,监听播放器的状态变化(如开始播放、暂停、缓冲等),以及动态加载播放列表或其他视频。适合需要高度自定义交互和复杂功能的场景。灵活性远高于直接 iframe 嵌入,但需一定开发成本。

地址:developers.google.com/youtube/ifr…

案例:Seven Knights Idle Adventure

16.gif

案例封装了 YouTube 视频播放器组件,onYouTubeIframeAPIReady 回调注册播放状态常量。使用计算属性来定义播放器的各项参数(尺寸、视频ID、按钮列表等),使得播放器组件可复用。并在 beforeDestroy 中调用 player.destroy() 销毁播放器实例防止内存泄漏。

window.onYouTubeIframeAPIReady = function() {
  o.YT = YT;
  var t = YT.PlayerState; 
  
  o.events[t.ENDED] = "ended",
  o.events[t.PLAYING] = "playing",
  o.events[t.PAUSED] = "paused",
  o.events[t.BUFFERING] = "buffering",
  o.events[t.CUED] = "cued";
  
  o.Vue.nextTick((function() {
    o.run()
  }));
};
...
computed: {
  youtube: function() { return this.item.args.youtube || { id: "" } }, 
  youtubeId: function() { return this.youtube.id },
  playerId: function() { return "".concat(this.item.id, "-player-").concat((new Date).getTime()) }, 
  width: function() { 
    return this.youtube.width ? "".concat(parseInt(this.youtube.width), "px") : "pc" === this.device ? "74vw" : "700px"
  },
  height: function() { 
    return this.youtube.height ? "".concat(parseInt(this.youtube.height), "px") : "pc" === this.device ? "".concat(41.625, "vw") : "393px"
  },
  vars: function() { return d({ rel: 0, wmode: "opaque" }, this.youtube.vars) } 
}
...
beforeDestroy: function() {
  "function" == typeof this.player.destroy && this.player.destroy()
}

💠 HLS.js

HLS.js 是一款开源的流媒体播放库,能让不支持 HTTP Live Streaming (HLS) 协议的现代浏览器,将视频流(如 MPEG-TS 片段)转换为浏览器可播放的格式(如 MP4),从而实现在网页中原生、流畅地播放 HLS 直播或点播视频,并支持 自适应码率 (ABR) 等关键功能以提升观看体验。

地址:github.com/video-dev/h…

案例:薩爾達傳說 王國之淚

image.png

案例自动播放、循环播放结合 hls.js 的分片加载能力,让视频在不同网络环境下都能流畅播放(如弱网时自动切换低清晰度分片)。优先使用 hls.js 播放 hls.loadSource(针对 Chrome、Firefox 等),否则降级使用 Safari 等浏览器的原生 HLS 支持,确保了几乎所有现代浏览器都能正常播放视频。

if (video.classList.contains('is_hls_ss')) {
  // 获取HLS源(.m3u8路径)
  var src = video.querySelector('source.active').dataset.src;
  // 初始化hls.js实例
  var hls = new (hls_default())();
  
  // 方案1:浏览器支持hls.js(如Chrome、Firefox)
  if (hls_default().isSupported()) {
    hls.loadSource(src); // 加载HLS源(解析.m3u8索引,获取.ts分片)
    hls.attachMedia(video); // 将HLS流关联到video元素
  } 
  // 方案2:浏览器原生支持HLS(如Safari)
  else if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.src = src; // 直接设置src为.m3u8,利用原生支持
    video.load();
  }
  
  // 监听视频加载完成事件(确保播放准备就绪)
  video.addEventListener('loadedmetadata', resolve);
  video.addEventListener('canplay', resolve);
}

💠 Video.js

Video.js 用于构建功能丰富、兼容性极强的网页视频播放器。不仅支持播放 MP4、WebM 等传统视频格式,还兼容 HLS、DASH 等现代自适应流媒体协议,确保了视频在不同浏览器和设备上的一致性与流畅体验。广泛应用于需要稳定、灵活视频播放解决方案的网站。

地址:videojs.org/

案例:Splatoon™ 3 for Nintendo Switch™

17.gif

案例实现了基于 Cloudinary 流媒体服务和 Video.js 的视频播放器。["hls/h265", "hls/h264"] 优先使用更高效的 H.265 编码,不支持则自动降级至兼容性更广的 H.264。accent: "#E60012" 将控制栏、进度条等元素的主题色设置为品牌色。volumechange 事件 + Cookie 存储监听音量变化,确保用户下次访问时保持之前的音量设置。

this.videoPlayerOptions = function() {
  ...
  return {
    ...
    source: {
      ...
      sourceTypes: e.startsWith("Legacy Videos/") ? ["hls/h264"] : ["hls/h265", "hls/h264"],
      sourceTransformation: {
        "hls/h264": [{ streaming_profile: "full_hd" }],
        "hls/h265": [{ streaming_profile: "h265_full_hd" }]
      }
    },
    ...
    colors: { accent: "#E60012", text: "#FFF" }, 
    ...
  };
};
...
this.volumeChange = function() {
  nclood.Cookie.set("nintendoVideoVolume", this.player.volume(), {
    maxAge: 365 * 24 * 60 * 60, 
    domain: n, path: "/"
  });
};

2.3 音频

控制角色语音、背景音效的播放与 3D 音效效果,增强官网的沉浸感和氛围感。

💠 Howler.js

howler.js 是一款轻量且功能强大的 JavaScript 音频库,它通过封装 Web Audio APIHTML5 Audio,为现代 Web 应用提供了简洁统一的 API。具备音频精灵、空间音效、音量控制、自动缓存、淡入淡出等高级功能,是网站处理音效与背景音乐的理想选择。

地址:github.com/goldfire/ho…

案例:Honkai: Star Rail

18.gif

案例基于 howler.js 构建了一个音频管理器,背景音乐 loop: true + preload: true,确保页面加载后无延迟循环播放。fade(0, initVolume, initFade) 播放时淡入,fade(volume, 0, 300) 暂停/停止时淡出。rate 播放速率默认1,支持变速播放,如特殊音效加速。

sounds[n] = new Howl({
  src: r, 
  volume: o, // 初始音量
  html5: l, 
  loop: f, 
  preload: d, 
  autoplay: p,
  rate: g // 播放速率(默认1,支持变速播放,如特殊音效加速)
}),
sounds[n].initVolume = o, // 存储初始音量(用于静音后恢复)
sounds[n].initFade = b, // 存储淡入淡出时长(统一音效过渡效果)
...
{
  key: "playSound",
  value: function(e) {
    var t = this, n = this.sounds[e];
    if (n) {
      var r = function() {
        !t.muted && n._initFade && n.fade(0, n._initVolume, n._initFade), // 淡入效果
        n.play()
      };
      "loaded" !== n.state() ? (n.once("load", r), n.load()) : r() // 未加载则先加载再播放
    } else console.warn("no sound: " + e)
  }
},
{
  key: "pauseSound",
  value: function(e, t) {
    var n = this.sounds[e];
    n && (t ? (n.fade(n._volume, 0, 300), n.once("fade", (function() {
      n.pause() // 淡出后暂停
    }))) : n.pause())
  }
}

💠 SoundManager

SoundManager 是一款老牌开源的音频管理库,提供了可靠且功能丰富的音频播放能力。简化了音频资源的加载、播放控制(如播放、暂停、音量调节、循环播放)和事件监听,并支持音频的淡入淡出等高级效果,极大地简化了在网页中集成音效、背景音乐等功能的开发流程。

地址:schillmania.com/projects/so…

案例:Genesis Augmented

案例构建了一套相当完善且用户体验良好的音频管理系统。使用 soundManager.createSound 创建音频对象实例。whileplaying 在播放中同步进度条,用户拖拽进度条时调用 setPosition 跳转播放位置。监听 blur/focus 事件实现页面切换时的音频淡入淡出。

this.api = soundManager.createSound({ 
  volume: 100,
  whileplaying: function() { this.step() }, // 播放中更新进度
  onplay: function() { t.addClass("nk-audio-plain-playing") }, 
  onpause: function() { t.removeClass("nk-audio-plain-playing") }, 
  onfinish: function() { this.seek(0); this.step(); ... } 
});
e.prototype = {
  // 进度更新:同步进度条与时间显示
  step: function() {
    var t = this.api.position || 0;
    this.progress = t / this.api.duration;
    this.$timer.html(this.formatTime(Math.round(t))); 
    this.$progress.css("width", "".concat(100 * this.progress || 0, "%"));
  },
  // 拖拽进度条跳转播放位置
  seek: function(t) {
    this.api.setPosition(this.api.duration * t); // t为0-1的比例值
  }
};
// 页面离开(blur)时淡出暂停,返回(focus)时淡入恢复
k.$wnd.on("blur focus", function(n) {
  setTimeout(function() {
    if ("blur" === n.type) {
      !a.paused && a.playState && (i = !0,
      t = a.volume,
      e = 1e3 / Math.abs(+t),
      clearInterval(l),
      l = setInterval(function() { // 淡出到0音量后暂停
        t = 0 < t ? t - 1 : t + 1,
        a.setVolume(t),
        0 === t && (clearInterval(l), a.pause())
      }, e))
    } else i && (i = !1, v()); // 恢复时淡入
  }, 0)
}));

2.4 评论系统

集成第三方服务,快速为官网添加用户评论、互动功能,构建社区氛围。

💠 Disqus

Disqus 是一款广泛应用的第三方嵌入式评论系统,为网站提供便捷的用户互动解决方案。它支持用户通过社交媒体账号(如 Google、Facebook)或 Disqus 账号登录,实现评论、回复、点赞、分享等功能,同时提供跨平台评论同步,提升用户体验的连贯性。

地址:disqus.com/

案例:Stardew Valley

image 1.png

案例展示了如何将 Disqus 评论系统集成到网站中,把 ID、URL、标题等注入到 JavaScript 变量中,Disqus 脚本获取并显示该页面对应的评论。language 配置确保不同地区玩家看到对应语言的评论区界面;sso 单点登录减少玩家评论门槛(无需单独注册 Disqus 账号),提升参与度。

<script type='text/javascript'>
/* <![CDATA[ */
var embedVars = {"disqusConfig":{"integration":"wordpress 3.0.17"},"disqusIdentifier":"1926 https:\/\/www.stardewvalley.net\/?p=1926","disqusShortname":"stardewvalley","disqusTitle":"Stardew Valley 1.5.5 Released on PC","disqusUrl":"https:\/\/www.stardewvalley.net\/stardew-valley-1-5-5-released-on-pc\/","postId":"1926"};
/* ]]> */
</script>
var disqus_config = function () {
  var dsqConfig = embedVars.disqusConfig;
  this.page.integration = dsqConfig.integration; // 声明集成环境为WordPress(Disqus适配其数据交互)
  this.page.remote_auth_s3 = dsqConfig.remote_auth_s3; // 远程认证参数(支持官网用户体系与Disqus联动,如自动登录)
  this.page.api_key = dsqConfig.api_key; // API密钥(用于Disqus高级功能,如数据统计)
  this.sso = dsqConfig.sso; // 单点登录配置(玩家可用官网账号直接登录评论区,无需重复注册)
  this.language = dsqConfig.language; // 评论区语言(默认跟随官网,适配全球玩家)

  if (disqus_config_custom) disqus_config_custom.call(this); // 允许自定义扩展配置(如添加评论过滤规则)
};

(function() {
  var dsq = document.createElement('script');
  dsq.type = 'text/javascript';
  dsq.async = true; // 异步加载(不阻塞官网页面渲染,保证玩家浏览更新内容时不卡顿)
  dsq.src = 'https://' + disqus_shortname + '.disqus.com/embed.js'; // 加载Disqus核心脚本(对应星露谷的专属评论脚本)
  // 将脚本插入页面(head或body,确保能正常执行)
  (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq);
})();

2.5 客服与反馈

💠 Zendesk

Zendesk 核心产品是一个 全渠道的智能客户服务与互动平台。通过整合网页、邮件、社交媒体、即时聊天、电话等多种沟通渠道,并利用自动化工单系统AI功能(如Answer Bot智能客服和数据分析)以及知识库工具,帮助高效地管理客户查询、优化支持流程并提升客户满意度。

地址:developer.zendesk.com/api-referen…

案例:Splinterlands

24.gif

案例实现了 Zendesk客服组件加载方案。小屏设备不加载插件,大屏动态加载脚本,script.id 确保能正确识别并初始化。通过专属 key 关联游戏定制化客服规则,确保玩家咨询(如链游资产异常、战斗 BUG)能精准分流到对应客服团队,提升问题解决效率。

if (!(window.innerWidth <= 800) && !(window.innerHeight <= 600)) {
var script = document.createElement('script');
script.setAttribute('id', 'ze-snippet');
script.setAttribute('src', 'https://static.zdassets.com/ekr/snippet.js?key=...');
document.head.appendChild(script);
}

三、性能优化与兼容性

确保官网在各种设备与网络环境下都能快速、稳定运行。

3.1 懒加载

延迟加载非关键资源(如图片、视频),提升首屏加载速度。

💠 LazySizes

LazySizes 是一款 图片延迟加载库,它通过智能检测元素是否进入浏览器视口来动态加载图片和 iframe,能显著提升页面加载速度、节省带宽,并因其不会向搜索引擎隐藏内容而保持 SEO 友好性;该库原生支持响应式图像,可自动计算适配屏幕尺寸的图片。

地址:github.com/aFarkas/laz…

案例:Wizardry Variants Daphne

12.gif

案例的只加载 1x1 像素的占位符 data:image/gif;base64,R0l...AA7,使首屏内容可以极速呈现。当滚动即将看到图片时才加载资源,并预设宽高比 data-aspectratio 避免布局抖动。picture 标签优先加载 data-srcset 中的 WebP 图片,否则降级加载 img 标签的 PNG 图片。

<picture>
    <!-- WebP格式图片(优先加载,体积更小) -->
    <source 
        srcset="data:image/gif;base64,R0lGODlhAQABAGAAACH5BAEKAP8ALAAAAAABAAEAAAgEAP8FBAA7" 
        data-srcset="/path/to/button.webp" 
        type="image/webp">
    
    <!-- PNG格式图片(降级方案,兼容不支持WebP的浏览器) -->
    <img 
    class="lazyload" 
    src="data:image/gif;base64,R0lGODlhAQABAGAAACH5BAEKAP8ALAAAAAABAAEAAAgEAP8FBAA7" 
    data-src="/path/to/button.png" alt="...">
</picture>

3.2 兼容性适配

解决旧浏览器对 HTML5/CSS3 的支持问题,适配低配置设备。

💠 Modernizr

Modernizr 的核心功能是检测用户浏览器对 HTML5 和 CSS3 各项特性的支持情况。它通过运行一系列快速的测试来判断浏览器是否支持各项特性,使开发者能够基于浏览器实际能力而非浏览器品牌和版本来编写 CSS 规则和 JavaScript 逻辑,从而更优雅地实现渐进增强优雅降级

地址:modernizr.com/

案例:Wizardry Variants Daphne

image 2.png

案例中 Modernizr 检测浏览器是否支持 WebP 格式,在 <html> 标签上添加 .webp 类(支持)或 .no-webp 类(不支持)。混合宏利用这两个类名,为不同浏览器提供对应的图片格式 —— 支持 WebP 的用体积更小的 WebP,不支持的用兼容性更好的 PNG。

@function replace($url) {
  $substr: '.png';
  $newsubstr: '.webp';
  $pos : str-index($url, $substr);
  $strlen : str-length($substr);
  $start : str-slice($url, 0, $pos - 1);
  $end : str-slice($url, $pos + $strlen);
  $url : $start + $newsubstr + $end;
  @return $url;
}
@mixin webp($url) {
  .no-webp & {
    background-image: url($url);
  }
  .webp & {
    background-image: url(replace($url));
  }
}

还有 更多特性检测,例如 Battery APICSS position: stickydetails Element 等等。

image 3.png

💠 HTML5 Shiv

💡 可忽略,是前端老兵的兼容代表方案。随着现代浏览器普及,项目已基本不需要。

HTML5 Shiv 是早期实现跨浏览器兼容性、推动开发者无顾虑采用 HTML5 新标准的重要工具之一。核心作用是让旧版 Internet Explorer 浏览器(特指 IE6-IE8) 能够识别并正确渲染 HTML5 新增的语义化标签(如 <article><section><nav><header> 和 <footer> 等)。

地址:github.com/aFarkas/htm…

案例:Wizardry Variants Daphne

<!--[if lt IE 9]>
    <script src="https://nie.res.netease.com/comm/html5/html5shiv.js "></script>
<![endif]-->

案例通过 HTML5 Shiv 为旧版浏览器提供一个 Polyfill,确保基础的内容和功能仍然可用,实现了优雅降级。同时,只有IE6、IE7、IE8会加载并执行这个脚本,而现代浏览器则会完全忽略这段代码,避免了不必要的资源消耗。

3.3 隐私合规

管理 Cookie 偏好设置、隐私政策生成,满足 GDPR 等全球合规要求。

💠 Cookiebot

Cookiebot 是一款即插即用式 Cookie 同意管理平台(CMP),其主要功能是通过在网站上嵌入可定制的支持 46 种语言的同意横幅、自动扫描并分类Cookie及追踪技术,并在获得用户明确同意前阻止这些技术的执行,来帮助网站所有者遵守 GDPRCCPA 等全球数据隐私法规。

地址:www.cookiebot.com/

案例:Home of the Cyberpunk 2077 universe

19.gif

案例实现了一个定制化的 Cookie同意管理解决方案,不仅完成了基本的合规要求,还实现了模态框集成和品牌化定制。根据 lang 设置 data-culture 属性智能识别用户语言,监听 CookiebotOnDialogDisplay 事件替换图片为自定义品牌图标。

var s = new n.modal({
  cssClass: ["cookie-declaration-modal"], // 自定义样式类(赛博朋克风格适配)
  onOpen: function() {
    ...
    s.close(), // 临时关闭,等待Cookiebot内容加载
    // Cookiebot声明加载完成后,检查内容是否溢出(确保显示完整)
    window.CookiebotCallback_OnDialogLoad = function() { s.checkOverflow() }
  },
  onClose: function() {
    delete window.CookiebotCallback_OnDialogLoad // 清除回调,避免内存泄漏
  }
});

// 点击“Cookie声明”链接时触发
i.addEventListener("click", (function(t) {
  t.preventDefault(); 
  var e = document.documentElement.lang; // 获取页面当前语言(如pt-br、zh-cn)
  var o = document.querySelector(".cookie-declaration-modal .tingle-modal-box__content");
  // 动态创建Cookiebot声明脚本
  var n = document.createElement("script");
  n.id = "CookieDeclaration",
  n.async = !0,
  // 根据页面语言设置Cookie声明的显示语言(多语言适配)
  n.setAttribute("data-culture", 
    "pt-br" === e || "pt-BR" === e ? "pt" : 
    "zh-cn" === e ? "zh" :
    "zh-tw" === e ? "zu" : 
    e 
  ),
  // 加载Cookiebot的Cookie声明脚本(关联官网的Cookiebot账户ID:acc3ad63-...)
  n.src = "https://consent.cookiebot.com/acc3ad63-2aea-464b-beeb-bd0b8a85bc05/cd.js",
  o.appendChild(n), 
  s.open() 
}));

// 监听Cookiebot同意弹窗显示事件
window.addEventListener('CookiebotOnDialogDisplay', function (e) {
  // 替换Cookiebot默认的“Powered by”图标为游戏自定义图标
  var el = document.getElementById('CybotCookiebotDialogPoweredbyImage');
  if (el) el.src = 'https://cyberpunk-static.qtlglb.com/build/images/cookies-icon-03723b68.png';
}, false);

💠 OneTrust

OneTrust 是一个企业级的隐私合规与数据治理平台,通过 Cookie 自动扫描、动态国家/州特定同意横幅、偏好中心及基于 Cookiepedia 数据库的预分类技术,帮助组织遵守 GDPR、CCPA 等全球隐私法规。可深度定制同意弹窗样式与交互流程,通过自动化工具简化合规流程、降低运营成本。

地址:www.onetrust.com/

案例:ELDEN RING NIGHTREIGN

20.gif

案例采用 React SSR + CSP 安全策略 + 动态脚本注入的方式,cdn-apac 亚太区CDN,加速亚洲用户访问。data-document-language 自动读取 HTML lang 属性切换语言。OptanonWrapper OneTrust 要求的全局函数钩子,用于在同意状态变更时执行自定义逻辑。

<meta http-equiv="Content-Security-Policy" content="script-src * data: https://cdn-apac.onetrust.com/scripttemplates/otSDKStub.js 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com 'unsafe-inline' 'unsafe-eval';"/>

// 加载OneTrust核心SDK脚本
h.jsx)(F.default, {
  src: "https://cdn-apac.onetrust.com/scripttemplates/otSDKStub.js", // 亚太区CDN,加速亚洲用户访问
  "data-document-language": "true", // 自动检测页面语言,适配多语言同意弹窗
  type: "text/javascript",
  charSet: "UTF-8",
  "data-domain-script": "e44e50cd-1032-4426-a8a0-0304ff8a035b-test" // 关联官网的OneTrust配置ID(测试环境)
}),

// 定义OneTrust回调函数(用户同意操作后触发)
h.jsx)(F.default, {
  id: "optanon-wrapper",
  dangerouslySetInnerHTML: {
    __html: "function OptanonWrapper() { }" // 默认空实现,可扩展为同意后启用分析/广告脚本
  }
})

💠 Osano

Osano 通过SaaS模式为企业提供一套完整的隐私合规解决方案,帮助企业自动化遵守全球隐私法规(如GDPR、CCR)。其核心平台集成了同意管理(Cookie横幅)、数据主体权利请求(DSAR)处理、供应商风险监控、数据映射和隐私评估等功能,特色是能够通过单行代码快速部署。

地址:www.osano.com/

案例:VALORANT

21.gif

案例基于 Osano 结合 Google Consent Mode,gtag('consent','default',{ 'ad_storage':'denied', ... }) 获取用户同意前默认拒绝所有广告、分析类的数据存储。点击自定义按钮时,通过window.Osano.cm.showDrawer()打开 Osano 的偏好设置面板,让用户可随时修改同意选项。Cookie 中携带 geo=SGlang=zh-CN,Osano 自动匹配当地法律要求(如 PDPA)。

{/* 加载Osano核心脚本,绑定官网专属合规政策 */}
<script
  id="osano-script"
  src={`https://cmp.osano.com/16BZ95S4qp9Kl2gUA/${page.osanoPolicyId}/osano.js`}
/>

{/* Google Consent脚本:默认禁用所有数据存储,等待用户同意 */}
gtag('consent','default',{
  'ad_storage':'denied',
  'analytics_storage':'denied',
  'ad_user_data':'denied',
  'ad_personalization':'denied',
  'wait_for_update': 500
});
gtag("set", "ads_data_redaction", true);

document.cookie = "osano_consentmanager_uuid=...; geo=SG; lang=zh-CN";

3.4 验证码

通过人机验证等手段,保护官网表单和业务接口免受机器人和恶意程序的攻击。

💠 GeeTest

GeeTest是一家来自中国的 交互安全服务提供商,其核心产品是 基于人工智能与行为式验证技术的智能验证码系统。能有效防御垃圾注册、撞库登录、恶意刷票等自动化攻击。与传统依赖文字扭曲识别的验证码不同,GeeTest 提供了如滑动拼图、图标点选等多种更具用户体验的验证形式。

地址:www.geetest.com/

案例:鸣潮

22.gif

案例集成 GeeTest 4.x 版本人机验证,language 多语言适配验证界面,riskType: "slide" 指定验证类型为 滑动验证,过 onReady/onSuccess/onError 等事件,同步控制加载弹窗的显示 / 隐藏,让用户清晰感知验证流程状态(如 “加载中→验证界面→验证完成”)。

function geetest(D, S) {
  return Ne(this, null, function*() {
    return yield new Promise( (E, x) => {
      // 语言映射:将前端语言标识转为GeeTest支持的格式
      const U = { "zh-Hans": "zho", "zh-Hant": "zho-tw", ja: "jpn", en: "eng" };
      const Y = { lot_number: "", captcha_output: "", pass_token: "", gen_time: "" };

      if (typeof initGeetest4 != "function") { S(Y); E(Y); return }

      Modal.showLoading(), // 显示加载弹窗,提升用户感知
      initGeetest4({
        captchaId: commonIds.captchaId, 
        language: (V = U[D]) != null ? V : "zho", // 适配验证界面语言
        riskType: "slide", // 指定验证类型为“滑动验证”
        product: "bind" 
      }, function(R) {
        // 验证组件加载完成:隐藏加载弹窗,显示验证界面
        R.onReady(function() { Modal.hideLoading(); R.showCaptcha() });
        // 验证成功:获取验证参数并返回(供登录接口使用)
        R.onSuccess(function() { S(R.getValidate()); E(R.getValidate()) });
        // 验证错误/失败/关闭:清理状态,记录日志
        R.onError(function(Z) { Modal.hideLoading(); x(Z); log("onError", Z) });
        R.onFail(function(Z) { Modal.hideLoading(); log("onFail", Z) });
        R.onClose(function() { Modal.hideLoading() });
      })
    })
  })
}

💠 reCAPTCHA

reCAPTCHA 是谷歌提供的验证码服务,旨在通过各种验证方式(如图像识别、滑块验证、点击验证等)区分人类用户和自动化机器人,广泛应用于网站登录、注册、评论等场景,以防止恶意攻击和滥用。同时支持多种语言和自定义样式,帮助网站开发者轻松集成并保护网站安全。

地址:developers.google.com/recaptcha

案例:Cyberpunk: Edgerunners

23.gif

案例用于保护订阅的表单,防止机器人自动提交。data-size="invisible" 使用隐形验证 —— 后台静默分析用户行为,仅当判定为高风险时才弹出验证,极大减少对用户的干扰。data-callback 指定验证成功后的回调函数,grecaptcha.execute() 执行验证。

<!-- 启用“隐形验证”模式 -->
<div id='recaptcha' class="g-recaptcha" 
     data-sitekey="6Lfta6oUAAAAAE5W9wJ12TZ9WBz7gAEANTt3UmoN"  
     data-callback="submitNewsletterForm" 
     data-size="invisible">  
</div>
n.on("submit", (function(t) {
  t.preventDefault(), // 拦截表单默认提交
  !c.prop("disabled") && (s ? grecaptcha.execute() : f(new FormData(n[0])))
}))

window.submitNewsletterForm = function(t) {
  f(new FormData(n[0])) // 验证成功后,提交表单
}

四、UI 组件与样式

构建用户界面的视觉语言、基础构件和设计规范,塑造游戏官网品牌视觉风格。

4.1 UI 框架/库

提供一套预置的样式类和组件,助力开发者快速构建符合游戏风格的界面。

💠 Tailwind CSS

Tailwind CSS 是一款以实用优先(Utility-First) 为核心的开源 CSS 框架,它提供海量原子化 CSS 工具类(如mt-4flexbg-blue-500),开发者可直接在 HTML 标签中组合这些工具类快速构建自定义界面,无需编写大量冗余的自定义 CSS。支持高度可定制的主题系统(自定义颜色、字体、间距),能 显著提升开发效率 并 确保设计一致性

地址:tailwindcss.com/

案例:FINAL FANTASY XVI | SQUARE ENIX

25.gif

案例使用 Tailwind CSS 构建的响应式导航栏组件。全程使用 px-4, mx-5, mb-[1px] 等原子类,消除 magic number。利用 lg/md/2xl 断点,兼顾 PC、平板、手机玩家的导航体验。data-[open=true]:-rotate-180 通过 数据属性状态类,实现下拉菜单展开时箭头旋转 180° 的交互。

<nav class="sticky top-[var(--header-bar-pos)] z-50 flex h-20 w-full items-center justify-center bg-gradient-to-b from-black/80 to-transparent font-bold text-white">
<div class="flex w-full max-w-screen-2xl items-center justify-between px-4 2xl:px-0">
<ul class="mx-5 hidden flex-1 flex-row lg:flex">
<li class="mx-5 cursor-pointer"></li>
...
<li class="mx-5 cursor-pointer">
<div class="relative">
<button type="button" data-open="false" class="flex items-center gap-2 hover:text-hampton data-[open=true]:text-hampton">DLC
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg" data-open="false" class="mb-[2px] duration-200 ease-in-out data-[open=true]:-rotate-180">
<path d="M4 6H11L7.5 10.5L4 6Z" fill="currentColor"></path>
</svg>
</button>
<ul data-open="false" class="absolute left-0 mt-1 w-full md:w-auto data-[open=false]:md:hidden">
<li class="mb-[1px] w-full whitespace-nowrap bg-black/60 p-2 text-base leading-none"><a class="hover:text-hampton" href="https://uk.finalfantasyxvi.com/dlc">Expansion Pass</a></li>
<li class="mb-[1px] w-full whitespace-nowrap bg-black/60 p-2 text-base leading-none"><a class="hover:text-hampton" href="https://uk.finalfantasyxvi.com/dlc/echoes-of-the-fallen">DLC: Echoes of the Fallen</a></li>
<li class="mb-[1px] w-full whitespace-nowrap bg-black/60 p-2 text-base leading-none"><a class="hover:text-hampton" href="https://uk.finalfantasyxvi.com/dlc/the-rising-tide">DLC: The Rising Tide</a></li>
</ul>
</div>
</li>
</ul>
<button type="button" class="block lg:hidden">Menu</button>
</div>
</nav>

💠 Bootstrap

Bootstrap 是由 Twitter 公司设计师开发的一款开源前端框架。它提供了一套丰富的预定义样式、组件(如导航栏、按钮、表单)和 JavaScript 插件,并以其灵活的栅格系统为核心,能够自动适配不同尺寸的屏幕。由于其简洁易用、文档完善且具有高度可定制性,被广泛应用于各类网站开发中。

地址:getbootstrap.com/

案例:Genesis Augmented | Official Website

29.gif

案例基于 Bootstrap 实现 响应式核心布局 开发。container 固定宽度容器,自动居中适配不同屏幕,row no-gutters 行容器,移除列之间的默认间距,让分栏更紧凑,是 Bootstrap 栅格的标准组合。col-md-5 是 Bootstrap 断点类(md 对应 768px),平板 / 桌面 两端分栏;手机 两列自动垂直堆叠,避免窄屏挤兑。

<div class="container">
        <div class="row no-gutters" style="justify-content: space-between; text-align: center;">
            <div class="col-md-5 image-margin-top">
                <img id="promo-title" draggable="false" ondragstart="return false;" oncontextmenu="return false;" loading="lazy" src="../crypto/assets/logo_light_sm.webp">
                <h3>Talk to <span class="avatar-select-name">Emma</span></h3>
                <p>- Ask me anything -</p>
                <div id="hero-loverboy" class="loverboy">
                    <textarea class="loverboy-output" readonly="" placeholder="Use the input box below to ask questions."></textarea>
                    <input id="loverboy-input" type="text" placeholder="Who is XMEG">
                </div>
                <a id="loverboy-transmit"><button class="nk-btn nk-btn-blue nk-btn-lg">Send Message</button></a>
            </div>
            <div id="avatar-profile-container" class="col-md-5 image-margin-top">
                <!-- ipad + desktop style = object-fit: contain; border-radius: 0; max-height: 600px; -->
                <img draggable="false" ondragstart="return false;" oncontextmenu="return false;" loading="lazy" id="avatar-profile-img" alt="Futurstic space elf girl from the Genesis Augmented Reality Trading Card Game" src="../img/emma.webp">
            </div>
        </div>
    </div>

💠 Radix UI

Radix UI是一款面向 React/Vue 生态的 无样式、无障碍优先的开源UI组件库,核心提供对话框、下拉菜单、滑块等基础交互组件,严格遵循 WAI-ARIA 规范,内置键盘导航、焦点管理与屏幕阅读器适配等无障碍能力。是平衡无障碍合规、交互稳定性与视觉个性化的现代前端解决方案。

地址:www.radix-ui.com/

案例:Grand Theft Auto VI - Rockstar Games

28.gif

案例使用了 Radix UI 的 Dialog 组件来构建多个 可访问、交互稳健的弹窗系统Dialog.Content 会自动通过 aria-labelledbyaria-describedby 属性与 Dialog.TitleDialog.Description 关联,这对于屏幕阅读器用户理解弹窗内容至关重要。支持 TAB 切换图片预览,Enter 打开弹窗 和 ESC 键关闭弹窗,这是用户预期的标准行为。

image 4.png

4.2 字体 & 图标

选用符合游戏风格的字体和图标库,是定义产品调性和确保界面清晰易用的基础。

💠 Google Fonts API

Google Fonts API 是一项免费的 Web 字体服务。该 API 支持指定多种字体、样式、粗细,并提供 font-display 控制字体加载行为、subset 参数下载特定语言子集、text 参数实现按需加载字体子集等优化功能,同时兼容国际字符和 UTF-8 编码,适用于各类网站和应用的字体需求。

地址:developers.google.cn/fonts/docs/…

案例:Seven Knights Idle Adventure - Netmarble

26.gif

案例 多语言动态字体加载 实现,采用 Nuxt.js SSR 架构,结合 Google Fonts API,为不同语言环境提供最优字体方案。Nuxt.js head() + 条件式字体加载,display=auto Google Fonts 自动选择最佳 font-display 策略。日语版本额外加载 RocknRoll One 字体,用于游戏标题、活动文案等视觉重点区域,通过个性化字体强化游戏的日系风格。

r = {
  en: "Palanquin+Dark:wght@700",    // 英文:粗体标题字体
  ja: "Noto+Sans+JP:wght@300;500",  // 日语:常规/中等字重,适配日文排版
  sc: "Noto+Sans+SC:wght@400;700;900", // 简体中文:常规/粗体/黑体,覆盖正文+标题
  tc: "Noto+Sans+TC:wght@400;700;900", // 繁体中文:适配繁体字形
  th: "Prompt:wght@400;700"         // 泰语:适配泰文字形
};

head() {
  return {
    link: [
      // 1. 基础 Noto Sans(全局)
      // 2. 条件:非 KO/SC/TC/JA/TH 时加载英文补充字体
      // 3. 条件:SC/TC/JA/TH 时加载对应语言字体
      // 4. 条件:KO/EN/SC/TC/JA 时加载 Netmarble 品牌字体
      // 5. 条件:JA 时额外加载 RocknRoll One(标题用)
      // 6. SEO: canonical + hreflang 多语言标签
      {
          hid: "google-webfont",
          rel: "stylesheet",
          href: "https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=auto"
      }, ["ko", "sc", "tc", "ja", "th"].includes(this.getLang) ? {} : {
          hid: "google-webfont-lang",
          rel: "stylesheet",
          href: "https://fonts.googleapis.com/css2?family=".concat(r.en, "&display=auto")
      }, ["sc", "tc", "ja", "th"].includes(this.getLang) ? {
          hid: "google-webfont-lang",
          rel: "stylesheet",
          href: "https://fonts.googleapis.com/css2?family=".concat(r[this.getLang], "&display=auto")
      } : {}, ["ko", "en", "sc", "tc", "ja"].includes(this.getLang) ? {
          hid: "google-webfont-lang",
          rel: "stylesheet",
          href: "https://sgimage.netmarble.com/font/v2/font.css"
      } : {}, ["ja"].includes(this.getLang) ? {
          hid: "google-webfont-lang",
          rel: "stylesheet",
          href: "https://fonts.googleapis.com/css2?family=RocknRoll+One&display=auto"
      } : {}, {
          hid: "canonical",
          rel: "canonical",
          href: "".concat(this.getDomain).concat(this.$route.fullPath)
      }, {
          hid: "alternate-x",
          rel: "alternate",
          href: "".concat(this.getDomain).concat(e),
          hreflang: "x-default"
      }
    ]
  }
}

💠 Adobe Fonts(Typekit)

Adobe Fonts(前身为 Typekit)是 Adobe Creative Cloud 旗下的专业字体服务,提供超过 20,000 种高质量字体,支持通过简单的 CSS 集成或桌面同步,在网页设计和创意项目中合法、无缝地使用字体,所有字体均已预授权并自动处理 Web 字体托管、优化和跨浏览器兼容性问题。

地址:helpx.adobe.com/cn/fonts/us…

案例:Roberts Space Industries

image 5.png

案例使用了 Adobe Fonts 服务来加载 Univia Pro 字体家族,这是一个完整的字体包,包含多个字重(覆盖100/400/500/600/700)和样式变体(normal/italic)。使用 @import 动态加载字体,每个变体都提供 WOFF2、WOFF 和 OpenType 格式。

@import url("https://p.typekit.net/p.css?s=1&k=dhw3beb&ht=tk&f=28764.28765.28767.28771.28772.28774.28775.28778.28779&a=86281447&app=typekit&e=css"); 
@font-face {
    font-family: "univia-pro";
    src: url("https://use.typekit.net/af/fbc5c1/00000000000000003b9add6d/27/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=i6&v=3") format("woff2"),url("https://use.typekit.net/af/fbc5c1/00000000000000003b9add6d/27/d?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=i6&v=3") format("woff"),url("https://use.typekit.net/af/fbc5c1/00000000000000003b9add6d/27/a?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=i6&v=3") format("opentype");
    font-display: auto;
    font-style: italic;
    font-weight: 600;
    font-stretch: normal;
}
/* 后续多个@font-face声明:覆盖100/400/500/600/700字重 + normal/italic样式 */

💠 Twitter Emoji(Twemoji)

Twitter Emoji(Twemoji)是 Twitter 开源的一套 Emoji 图标库,它通过将 Unicode 标准中的 Emoji 字符转换为统一风格的图片(如 SVG 或 PNG),解决了不同操作系统和设备上 Emoji 显示效果不一致的问题,确保在所有平台上呈现 完全相同的视觉效果

地址:github.com/twitter/twe…

案例:Stardew Valley

image 6.png

案例

💠 Font Awesome

Font Awesome 是一款开源、可免费商用 的图标字体库和 CSS 框架,提供数千个可缩放的矢量图标,通过简单的 CSS 类名即可快速集成到网站中,支持多种风格和格式,并可通过 kits 和 API 实现按需加载、自定义图标集和动态图标,是前端开发中提升 UI 效率和一致性的标准解决方案。

地址:developers.google.cn/fonts/docs/…

案例:Hogwarts Legacy - Principal

27.gif

案例集成了 Font Awesome 图标库,实现了 社交媒体导航栏。使用 Vue 专用组件 <font-awesome-icon>,通过 fab 前缀指定品牌图标库,确保图标渲染高效精准。fixed-width 属性强制所有图标保持相同宽度,保证导航栏视觉对齐和美观度。

<ul>
<li>
<a class="nav-link dc"
           href="https://discord.gg/HogwartsLegacy"
           title="Discord"
           target="_blank"
           rel="noopener"
           aria-label="Visit Discord.com"
           data-toggle="tooltip" data-placement="bottom">
            <font-awesome-icon :icon="['fab', 'discord']" fixed-width></font-awesome-icon>
</a>
</li>
...
</ul>

总结

没有“万能”的工具,只有“最适合”的方案。技术选型,是在 视觉表现用户体验开发效率性能指标 之间的平衡。选择社区活跃、文档完备的开源工具或成熟的商业服务,能为项目的长期维护和团队协作降低大量成本。希望本篇文章能为游戏官网开发者提供灵感和参考。

从零构建一个现代登录页:深入解析 Tailwind CSS + Vite + Lucide React 的完整技术栈

作者 AAA阿giao
2026年3月1日 18:25

引言

在当今前端开发的快节奏世界中,开发者们不再满足于“能用”的界面,而是追求高效、美观、可维护且体验流畅的 UI。而要实现这一目标,一套现代化的技术组合至关重要。

本文将带你从零开始,使用 ViteTailwind CSSLucide React 构建一个专业级的登录页面,并对每一行代码、每一个 Tailwind 工具类进行逐层拆解与深度解析。我们将不仅告诉你“怎么写”,更要解释“为什么这样写”、“背后原理是什么”、“如何举一反三”。

📌 核心目标:让你彻底掌握 Tailwind CSS 的思维方式,理解现代 React 应用的工程结构,并能独立构建高保真、响应式、交互丰富的用户界面。


第一部分:技术选型 —— 为什么是 Vite + Tailwind + Lucide?

Vite:下一代前端构建工具

Vite 由 Vue.js 作者尤雨溪打造,利用原生 ES 模块(ESM)和浏览器对 import 的原生支持,实现了闪电般的冷启动速度毫秒级热更新。它摒弃了传统打包器(如 Webpack)在开发时“先打包再运行”的模式,转而采用“按需编译”,极大提升了开发体验。

对于新项目,官方推荐使用:

npm create vite@latest my-project -- --template react

Tailwind CSS:原子化 CSS 的革命者

Tailwind CSS 不是一个组件库,而是一个 Utility-First(实用优先) 的 CSS 框架。它提供数千个低层级的 CSS 类(如 p-4text-centerbg-blue-500),让你直接在 HTML/JSX 中组合出任意设计。

💡 关键理念“你不需要写一行自定义 CSS,就能构建完全定制化的 UI。”

优势包括:

  • 开发速度极快:所见即所得,无需切换文件。
  • 天然响应式md:p-10 这样的前缀让适配屏幕轻而易举。
  • 自动 Purge(Tree-shaking) :只打包你实际使用的类,生产包体积极小。
  • 主题一致性:所有颜色、间距、圆角都来自同一套设计系统(Design Token)。

Lucide React:轻量、类型安全的 SVG 图标库

LucideFeather Icons 的社区驱动继任者,提供超过 1000 个精心设计的开源图标。其 React 版本 lucide-react 具备以下优点:

  • 每个图标都是独立的 React 组件,支持 TypeScript。
  • 完全 tree-shakable:只打包你导入的图标。
  • 高度可定制:通过 sizecolorstrokeWidth 等 props 控制外观。
  • 渲染为内联 SVG:无额外 HTTP 请求,性能优异。

安装命令:

pnpm add lucide-react

第二部分:工程搭建 —— 零配置集成 Tailwind 到 Vite

根据 Tailwind 官方 Vite 安装指南,我们只需四步:

步骤 1:创建 Vite 项目(如果尚未创建)

npm create vite@latest tailwindcss-login -- --template react
cd tailwindcss-login

步骤 2:安装依赖

npm install tailwindcss @tailwindcss/vite

⚠️ 注意:这里使用的是 @tailwindcss/vite 插件,这是 Tailwind v4 推出的新方式,无需 PostCSS 配置,简化了集成流程。

步骤 3:配置 Vite

编辑 vite.config.ts(或 .js):

import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [tailwindcss()],
})

步骤 4:引入 Tailwind CSS

在你的主样式文件(如 src/index.css)中添加:

@import "tailwindcss";

然后在 main.jsxApp.jsx 中确保该 CSS 被引入。

步骤 5:启动开发服务器

npm run dev

✅ 恭喜!你现在可以在任何组件中自由使用 Tailwind 的所有工具类了。


第三部分:业务逻辑 —— React 状态与受控组件

在 React 中,表单的最佳实践是使用 受控组件(Controlled Components) —— 即表单元素的值由 React 的 state 驱动,而非 DOM 自己管理。这确保了 UI 与数据状态始终保持同步。

核心状态定义

const [formData, setFormData] = useState({
  email: '',
  password: '',
  rememberMe: false
})
  • emailpassword 是字符串,用于文本输入框。
  • rememberMe 是布尔值,用于复选框。

通用事件处理器

const handleChange = (e) => {
  const { name, value, type, checked } = e.target;
  setFormData((prev) => ({
    ...prev,
    [name]: type === "checkbox" ? checked : value
  }));
}
  • 使用 计算属性名 [name] 动态更新对应字段。
  • 区分 input(取 value)和 checkbox(取 checked)。

密码可见性切换

const [showPassword, setShowPassword] = useState(false);
// 在 input 的 type 中动态切换
type={showPassword ? "text" : "password"}

加载状态(预留)

const [isLoading, setIsLoading] = useState(false);

虽然当前 handleSubmit 是空的,但未来可在此处调用 API,并设置 setIsLoading(true) 来禁用按钮、显示 loading 动画等。


第四部分:深度解析 —— Tailwind 工具类全解

项目源码链接:react/tailwindcss-login/src/App.jsx · Zou/lesson_zp - 码云 - 开源中国

接下来,我们将逐层、逐类、逐像素地解析这个 UI 的构建逻辑。

1. 页面容器:撑满屏幕并居中

<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
类名 含义 技术细节
min-h-screen 最小高度 = 100vh 确保即使内容很少,页面也占满整个视口,避免“短页面”出现空白。
bg-slate-50 背景为浅灰蓝 slate-50 是 Tailwind 默认调色板中最浅的中性色,柔和不刺眼。
flex 启用 Flexbox 布局 现代布局的基石。
items-center 交叉轴(垂直)居中 子元素在垂直方向上居中。
justify-center 主轴(水平)居中 子元素在水平方向上居中。
p-4 内边距 1rem (16px) 为移动端提供安全边距,防止内容贴边。

📏 单位说明:Tailwind 的默认间距单位基于 0.25rem(4px)。所以 p-4 = 4 * 4px = 16px


2. 登录卡片:视觉焦点与层次感

<div className="relative z-10 w-full max-w-md bg-white rounded-3xl shadow-xl shadow-slate-200/60 border border-slate-100 p-8 md:p-10">
类名 含义 技术细节
relative z-10 相对定位 + 层级提升 为内部绝对定位元素建立上下文;z-10 确保卡片在背景之上(虽非必需,但良好习惯)。
w-full 宽度 100% 占满父容器(即 p-4 后的可用宽度)。
max-w-md 最大宽度 28rem (448px) 在大屏设备上限制宽度,避免文字行长过长影响阅读。
bg-white 纯白背景 bg-slate-50 形成对比,突出内容区域。
rounded-3xl 圆角 1.5rem (24px) 超大圆角,营造现代、友好的感觉。
shadow-xl 大阴影 对应 CSS: box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);
shadow-slate-200/60 阴影颜色 + 透明度 将默认黑色阴影替换为 slate-200 并设 60% 透明度,更柔和自然。
border border-slate-100 1px 边框 slate-100 几乎是白色,在浅背景下提供微妙分隔线。
p-8 md:p-10 内边距响应式 手机: 2rem (32px);中屏及以上: 2.5rem (40px),提升桌面体验。

🌐 响应式前缀md: 表示“中等屏幕及以上”(默认断点 ≥768px)。Tailwind 采用 Mobile First 策略,所有类默认作用于最小屏幕,更大屏幕通过前缀覆盖。


3. 顶部图标与标题

<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-600 text-white mb-4 shadow-lg shadow-indigo-200">
  <Lock size={24}/>
</div>
类名 含义
inline-flex 行内 Flex 容器
w-12 h-12 3rem × 3rem (48px × 48px)
rounded-xl 圆角 0.75rem (12px)
bg-indigo-600 品牌主色背景
text-white 白色文字/图标
shadow-lg shadow-indigo-200 发光效果

标题文字使用 text-slate-900(接近黑)和 text-slate-500(中灰),形成清晰的视觉层次。


4. 表单结构:间距与分组

<form className='space-y-6'>
  <div className="space-y-2">...</div>
</form>
  • space-y-6子元素之间垂直间距 1.5rem (24px)。这是 Tailwind 的 “间距组” 功能,避免手动写 margin-top
  • space-y-2:label 与 input 之间间距 0.5rem (8px)。

💡 原理space-y-N 会为除第一个子元素外的所有子元素添加 margin-top: N * 0.25rem


5. 输入框布局:绝对定位与交互反馈

每个输入框都被包裹在 relative group 中:

<div className="relative group">
  <!-- 左侧图标 -->
  <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-indigo-600 transition-colors">
    <Mail size={18} />
  </div>
  <!-- 输入框 -->
  <input className="block w-full pl-11 pr-4 py-3 ..." />
</div>

定位系统

  • relative:为内部 absolute 元素建立定位上下文。
  • absolute inset-y-0 left-0:图标容器垂直拉满(top: 0; bottom: 0),贴左对齐。
  • pl-4:图标容器内部左填充 1rem (16px),控制图标与边界的距离。
  • pl-11(输入框):左填充 2.75rem (44px),为图标预留空间(图标约 18px + pl-4 ≈ 34px,留有余量)。

交互状态

  • pointer-events-none:禁止图标接收鼠标事件,避免点击图标时无法聚焦 input。
  • group-focus-within:text-indigo-600:当 .group 内任意子元素(如 input)获得焦点时,图标颜色变为品牌色。这是实现“聚焦高亮”的关键。
  • transition-colors:颜色变化时添加平滑过渡(默认 150ms ease)。

输入框自身样式

className="block w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600 transition-all"
类名 作用
block 块级元素,独占一行
w-full 宽度 100%
py-3 上下内边距 0.75rem (12px),增大点击区域
pr-4 右内边距,为密码切换按钮留空间
bg-slate-50 浅灰背景,区别于白色卡片
border border-slate-200 极浅灰色边框
rounded-xl 12px 圆角
text-slate-900 深色文字,保证可读性
placeholder:text-slate-400 placeholder 文字为浅灰色(注意:这不是伪类,而是对 ::placeholder 的封装)
focus:outline-none 移除浏览器默认蓝色轮廓
focus:ring-2 添加 2px 宽的“环形阴影”(位于边框外)
focus:ring-indigo-600/20 ring 颜色为品牌色 + 20% 透明度,柔和高亮
focus:border-indigo-600 边框变品牌色,明确指示当前字段
transition-all 所有可变属性(颜色、边框、阴影)都启用过渡动画

🎯 伪类前缀:Tailwind 使用 hover:focus:group-focus-within: 等前缀来模拟 CSS 伪类。例如 focus:border-indigo-600 编译为:

.focus:border-indigo-600:focus {
  border-color: #4f46e5;
}

👁️ 6. 密码可见性切换

<button
  type="button"
  onClick={() => setShowPassword(!showPassword)}
  className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-slate-600 transition-colors"
>
  {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
  • absolute inset-y-0 right-0:按钮垂直拉满,贴右对齐。
  • pr-4:内部右填充,控制图标与右边界的距离。
  • hover:text-slate-600:悬停时颜色变深,提示可点击。
  • 使用 EyeEyeOff 图标动态切换,直观表达状态。

7. “忘记密码?”链接

<a href="#" className="text-sm font-medium text-indigo-600 hover:text-indigo-500 transition-colors">
  忘记密码?
</a>
  • 使用品牌色 text-indigo-600 引导用户操作。
  • hover:text-indigo-500 提供悬停反馈。
  • ml-1(在父容器)微调左外边距,使对齐更精确。

第六部分:特别说明 —— 关于 placeholder 和伪类

虽然代码中使用了:

placeholder:text-slate-400

但这不是伪类,而是 Tailwind 对 ::placeholder 伪元素的直接封装。

真正的伪类组合示例(虽未使用):

focus:placeholder:text-indigo-500

表示“当 input 聚焦时,placeholder 文字变为 indigo-500”。

📚 伪类 vs 伪元素

  • 伪类:hover, :focus):描述元素的状态。
  • 伪元素::before, ::placeholder):创建不在文档中的虚拟元素。

Tailwind 对两者都提供了前缀支持,但语法略有不同。


第七部分:总结与展望

通过这个登录页,我们不仅实现了一个美观、响应式的 UI,更重要的是掌握了:

  1. 现代前端工程化流程:Vite + Tailwind 的零配置集成。
  2. 原子化 CSS 思维方式:用组合代替继承,用工具类代替手写 CSS。
  3. React 状态管理最佳实践:受控组件、通用事件处理。
  4. 高级布局技巧:Flexbox 居中、绝对定位嵌套、间距组。
  5. 交互细节打磨:聚焦高亮、悬停反馈、过渡动画、品牌色贯穿。
  6. 第三方库集成:Lucide React 的按需引入与定制。

下一步你可以做什么?

  • 添加表单验证:使用 react-hook-form + zod
  • 实现加载状态:在 handleSubmit 中设置 isLoading,并禁用按钮。
  • 抽象 Input 组件:将带图标的 input 封装为可复用组件。
  • 主题切换:利用 Tailwind 的 dark: 前缀实现暗色模式。
  • 国际化:使用 react-i18next 支持多语言。

结语

前端开发不再是“切图 + 写 CSS”的体力活,而是一门融合工程、设计与用户体验的艺术。Tailwind CSS 让你从繁琐的样式命名和调试中解放出来,专注于构建真正有价值的用户界面

正如 Tailwind 官方所说:

“You aren’t limited to the design you started with — you can customize everything.”

而今天,你已经迈出了第一步。

Happy coding! 🚀

用 CSS 打造完美的饼图

2026年2月28日 23:59

原文:Trying to Make the Perfect Pie Chart in CSS

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

说到图表……你上次使用饼图是什么时候?如果你是那些需要到处做演示的人之一,那么恭喜!你既在我个人的地狱里……也被饼图包围着。幸运的是,我想我很久没需要用过它们了,至少直到最近是这样。

去年,我自愿为墨西哥的一个儿童慈善机构制作网页。一切都很标准,但工作人员希望在他们的落地页上以饼图展示一些数据。他们给我们的时间不多,所以我承认我走了捷径,使用了众多用于制作图表的 JavaScript 库之一。

看起来不错,但内心深处我感到不安;为几个简单的饼图引入整个库。感觉像是走捷径,而不是打造真正的解决方案。

我想弥补这一点。在本文中,我们将尝试用 CSS 制作完美的饼图。这意味着在解决手写饼图带来的主要头痛问题的同时,尽可能减少 JavaScript。但首先,让我们设定我们的「完美」应该遵守的一些目标。

按优先级排序:

  1. 应该将 JavaScript 保持在最低限度!不是对 JavaScript 有意见,只是这样更有趣。
  2. 应该是 HTML 可定制的!一旦 CSS 完成,我们只需要修改标记就可以自定义饼图。
  3. 必须是语义化的!这意味着屏幕阅读器应该能够理解饼图中显示的数据。

完成后,我们应该得到像这样的饼图:

这要求太多吗?也许吧,但无论如何我们会试试。

圆锥渐变(conic gradients)不是最佳选择

我们不能在谈论饼图时不先谈谈圆锥渐变。如果你读过任何与 conic-gradient() 函数相关的内容,那么你可能已经看到它们可以用来在 CSS 中创建简单的饼图。见鬼,甚至我在年鉴条目中也这么说过。为什么不呢?只需要一个元素和一行 CSS……

.gradient {
  background: conic-gradient(blue 0% 12.5%, lightblue 12.5% 50%, navy 50% 100%);
}

我们可以得到无缝完美的饼图:

CodePen Embed Fallback

然而,这种方法公然违背了我们语义化饼图的第一个目标。正如同一条目后面所指出的:

不要使用 conic-gradient() 函数创建真正的饼图或任何其他信息图。它们不包含任何语义含义,应仅用于装饰目的。

请记住,渐变是图像,因此将渐变显示为 background-image 不会告诉屏幕阅读器关于饼图本身的任何信息;它们只能看到一个空元素。

这也违背了我们的第二条规则,即让饼图可通过 HTML 定制,因为对于每个饼图,我们都必须更改其对应的 CSS。

那么我们是否应该完全抛弃 conic-gradient()?尽管我很想这么做,但它的语法太好了,不能错过,所以让我们至少尝试弥补它的缺点,看看能带我们走到哪里。

改进语义

conic-gradient() 第一个也是最严重的问题是它的语义。我们想要一个包含所有数据的丰富标记,以便屏幕阅读器能够理解。我必须承认我不知道语义化书写的最佳方式,但在使用 NVDA 测试后,我相信这是一个足够好的标记:

<figure>
  <figcaption>上月售出的糖果</figcaption>
  <ul class="pie-chart">
    <li data-percentage="35" data-color="#ff6666"><strong>巧克力</strong></li>
    <li data-percentage="25" data-color="#4fff66"><strong>软糖</strong></li>
    <li data-percentage="25" data-color="#66ffff"><strong>硬糖</strong></li>
    <li data-percentage="15" data-color="#b366ff"><strong>泡泡糖</strong></li>
  </ul>
</figure>

理想情况下,这就是我们饼图所需要的全部,一旦样式完成,只需编辑 data-* 属性或添加新的 <li> 元素即可更新我们的饼图。

不过有一点:在目前的状态下,data-percentage 属性不会被屏幕阅读器朗读出来,所以我们必须将它作为伪元素附加到每个项目的末尾。记得在末尾加上「%」以便一起朗读:

.pie-chart li::after {
  content: attr(data-percentage) "%";
}

CodePen Embed Fallback

那么,它是否具有可访问性?至少在 NVDA 中测试时是的。这是 Windows 上的效果:

你可能对我为什么选择这个或那个有一些疑问。如果你信任我,我们继续,但如果不,这是我的思考过程:

为什么使用 data 属性而不是直接写入每个百分比?

我们很容易将它们写在每个 <li> 里面,但使用属性我们可以通过 attr() 函数在 CSS 中获取每个百分比。正如我们稍后将看到的,这使得在 CSS 中使用它变得容易得多。

为什么用 <figure>

<figure> 元素可以作为我们饼图的自包含包装器使用,除了图像之外,它也经常用于图表。很方便,因为我们可以通过 <figcaption> 给它一个标题,然后在无序列表中写出数据,我之前不知道 figure 允许的内容 中包括 ul 作为流内容

为什么不用 ARIA 属性?

我们可以使用 aria-description 属性让屏幕阅读器朗读每个项目对应的百分比,这可能是最重要的部分。然而,我们可能也需要在视觉上显示图例。这意味着在语义和视觉上都有百分比没有优势,因为它们可能会被朗读两次:(1)在 aria-description 上一次,(2)在伪元素上又一次。

做成饼图

我们已经在纸上有了数据。现在是时候让它看起来像一个真正的饼图了。我首先想到的是,「这应该很容易,有了标记,我们现在可以使用 conic-gradient() 了!」

嗯……我大错特错了,但不是因为语义,而是因为 CSS 层叠的工作原理。

让我们再看看 conic-gradient() 的语法。如果我们有以下数据:

  • 项目 3:50%
  • 项目 2:35%
  • 项目 1:15%

……那么我们会写下以下 conic-gradient()

.gradient {
  background: 
    conic-gradient(
      blue 0% 15%, 
      lightblue 15% 50%, 
      navy 50% 100%
    );
}

这基本上是说:「从 0 到 15% 画第一种颜色,下一种颜色从 15% 到 50%(所以差值是 35%),以此类推。」

你看到问题了吗?饼图是在单个 conic-gradient() 中绘制的,这等于单个元素。你可能看不到,但这很糟糕!如果我们想在 data-percentage 中显示每个项目的权重——让一切更漂亮——那么我们需要一种从父元素访问所有这些百分比的方法。这是不可能的!

我们能够利用 data-percentage 简单性的唯一方法是每个项目绘制自己的扇形。然而,这并不意味着我们不能使用 conic-gradient(),而是我们需要使用多个。

计划是让每个项目都有自己的 conic-gradient() 绘制其扇形,然后将它们全部叠在一起:

为此,我们首先给每个 <li> 一些尺寸。我们不会硬编码大小,而是定义一个 --radius 属性,这在后面保持样式可维护时会很有用。

.pie-chart li {
  --radius: 20vmin;

  width: calc(var(--radius) * 2); /* 半径的两倍 = 直径 */
  aspect-ratio: 1;
  border-radius: 50%;
}

然后,我们使用 attr() 及其新类型语法data-percentage 属性引入 CSS,该语法允许我们将属性解析为字符串以外的内容。请注意,在我写这篇文章时,新语法目前仅限于 Chromium。

然而,在 CSS 中使用小数(如 0.1)比使用百分比(如 10%)更好,因为我们可以将它们乘以其他单位。所以我们将 data-percentage 属性解析为 <number>,然后除以 100 得到小数形式的百分比。

.pie-chart li {
  /* ... */
  --weighing: calc(attr(data-percentage type(<number>)) / 100);
}

我们仍然需要它作为百分比,这意味着将结果乘以 1%

.pie-chart li {
  /* ... */
  --percentage: calc(attr(data-percentage type(<number>)) * 1%);
}

最后,我们再次使用 attr() 从 HTML 获取 data-color 属性,但这次使用 <color> 类型而不是 <number>

.pie-chart li {
  /* ... */
  --bg-color: attr(data-color type(<color>));
}

让我们暂时把 --weighing 变量放在一边,使用另外两个变量创建 conic-gradient() 扇形。它们应该从 0% 到所需百分比,然后 thereafter 变为透明:

.pie-chart li {
  /* ... */
   background: conic-gradient(
   var(--bg-color) 0% var(--percentage),
   transparent var(--percentage) 100%
  );
}

我显式定义了起始 0% 和结束 100%,但由于这些是默认值,我们 technically 可以删除它们。

这是我们目前的进度:

CodePen Embed Fallback

如果你的浏览器不支持新的 attr() 语法,也许一张图片会有所帮助:

现在所有扇形都完成了,你会注意到每个扇形都从顶部开始,顺时针方向延伸。我们需要将它们定位成,你知道的,饼图形状,所以下一步是适当旋转它们以形成圆形。

就在这时我们遇到了一个问题:每个扇形旋转的量取决于它前面的项目数量。我们必须将项目旋转前面扇形的大小。理想情况下,有一个累加器变量(如 --accum)保存每个项目之前百分比的总和。然而,由于 CSS 层叠的工作方式,我们既不能在兄弟之间共享状态,也不能在每个兄弟上更新变量。

相信我,我真的努力绕过这些问题。但我们似乎被迫在两个选项之间做出选择:

  1. 使用 JavaScript 计算 --accum 变量。
  2. 在每个 <li> 元素上硬编码 --accum 变量。

如果我们重新审视我们的目标,选择并不难:硬编码 --accum 会否定灵活的 HTML,因为移动项目或更改百分比会迫使我们再次手动计算 --accum 变量。

然而,JavaScript 使这变得微不足道:

const pieChartItems = document.querySelectorAll(".pie-chart li");

let accum = 0;

pieChartItems.forEach((item) => {
  item.style.setProperty("--accum", accum);
  accum += parseFloat(item.getAttribute("data-percentage"));
});

有了 --accum,我们可以使用 from 语法 旋转每个 conic-gradient(),该语法告诉圆锥渐变旋转的起点。问题是它只接受角度,不接受百分比。(我觉得百分比也应该可以工作,但这是另一个话题)。

为了解决这个问题,我们必须创建另一个变量——我们称它为 --offset——它等于转换为角度的 --accum。这样,我们可以将值插入每个 conic-gradient()

.pie-chart li {
  /* ... */
  --offset: calc(360deg * var(--accum) / 100);

  background: conic-gradient(
    from var(--offset),
    var(--bg-color) 0% var(--percentage),
    transparent var(--percentage) 100%
  );
}

我们看起来好多了!

CodePen Embed Fallback

剩下的就是把所有项目叠在一起。当然有很多方法可以做到这一点,但最简单的可能是 CSS Grid。

.pie-chart {
  display: grid;
  place-items: center;
}

.pie-chart li {
  /* ... */
  grid-row: 1;
  grid-column: 1;
}

这几行 CSS 将所有扇形排列在 .pie-chart 容器的正中心,每个扇形覆盖容器的唯一行和列。它们不会碰撞,因为它们被正确旋转了!

CodePen Embed Fallback

除了那些重叠的标签,我们的状态真的非常非常好!让我们清理一下。

定位标签

现在,<li> 里面的名称和百分比标签彼此散落在一起。我们希望它们浮动在各自扇形的旁边。为了修复这个问题,让我们首先使用与容器本身相同的网格居中技巧,将所有项目移动到 .pie-chart 容器的中心:

.pie-chart li {
  /* ... */
  display: grid;
  place-items: center;
}

.pie-chart li::after,
strong {
  grid-row: 1;
  grid-column: 1;
}

幸运的是,我已经探索过如何使用较新的 CSS 的 cos()sin() 在圆上布局东西。去看看那些链接,因为那里有很多上下文。简而言之,给定一个角度和半径,我们可以使用 cos()sin() 来获取圆上每个项目的 X 和 Y 坐标。

为此,我们需要——你猜对了!——另一个表示角度的 CSS 变量(我们称之为 --theta),我们将在那里放置每个标签。我们可以用下一个公式计算该角度:

.pie-chart li {
  /* ... */
  --theta: calc((360deg * var(--weighing)) / 2 + var(--offset) - 90deg);
}

值得了解该公式在做什么:

  • - 90degcos()sin() 的角度从右边测量,但 conic-gradient() 从顶部开始。这部分通过 -90deg 校正每个角度。
  • + var(--offset):移动角度以匹配当前偏移。
  • 360deg * var(--weighing)) / 2:将百分比作为角度获取,然后除以二以找到中点。

我们可以使用 --theta--radius 变量找到 X 和 Y 坐标,如下面的伪代码:

x = cos(theta) * radius
y = sin(theta) * radius

翻译成……

.pie-chart li {
  /* ... */
  --pos-x: calc(cos(var(--theta)) * var(--radius));
  --pos-y: calc(sin(var(--theta)) * var(--radius));
}

这会将每个项目放在饼图的边缘,所以我们会在它们之间添加一个 --gap

.pie-chart li {
  /* ... */
  --gap: 4rem;
  --pos-x: calc(cos(var(--theta)) * (var(--radius) + var(--gap)));
  --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap)));
}

然后我们用 --pos-x--pos-y 平移每个标签:

.pie-chart li::after,
strong {
  /* ... */
  transform: translateX(var(--pos-x)) translateY(var(--pos-y));
}

哦等等,还有一个小细节。每个项目的标签和百分比仍然叠在一起。幸运的是,修复就像在 Y 轴上再多平移一点百分比一样简单:

.pie-chart li::after {
  --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap)) + 1lh);
}

现在我们在用煤气做饭了!

CodePen Embed Fallback

让我们确保这对屏幕阅读器友好:

暂时就这些……

我会称这是朝着「完美」饼图迈出的非常好的第一步,但仍有一些我们可以改进的地方:

  • 这似乎迫切需要一种漂亮的悬停效果,比如 maybe 放大扇形并显示它?
  • 不同类型的图表呢?柱状图,有人要吗?
  • data-color 属性很好,但如果没有提供,我们仍然应该提供一种让 CSS 生成颜色的方式。也许是 color-mix() 的好工作?
  • 饼图假设你会自己写百分比,但应该有一种方式输入原始项目数量,然后计算它们的百分比。

这就是我目前能想到的全部,但我已经在计划在后续文章中逐步解决这些问题(懂吗?!)。此外,没有大量反馈就没有完美,所以告诉我你会改变或添加什么到这个饼图中,让它真正完美!


纯 CSS 实现弹性文字效果

2026年3月1日 00:00

原文:How to Create a CSS-only Elastic Text Effect

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

每个字母单独动画的文字效果总是很酷、很吸睛。这类错峰动画通常依赖 JavaScript 库实现,对我们要实现的这种相对轻量的设计效果来说,代码往往偏重。本文将探索只用 CSS、无需 JavaScript 实现 fancy 文字效果的技巧(意味着需要手动拆分字符)。

截至撰写时,仅 Chrome 和 Edge 完全支持我们使用的特性。

将鼠标悬停在下方演示的文字上,即可看到效果:

CodePen Embed Fallback

很酷吧?仅靠 CSS 就实现了逼真的弹性效果,而且灵活易调。在深入代码之前,先做一个重要声明。这个效果不错,但有几个明显的缺点。

关于可访问性的重要声明

我们要做的效果依赖于把单词拆成单个字母,一般来说这种做法非常不推荐。

一个简单链接通常是这样写的:

<a href="#">About</a>Code language: HTML, XML (xml)

但要分别控制每个字母的样式,我们会改成这样:

<a href="#">
  <span>A</span><span>b</span><span>o</span><span>u</span><span>t</span>
</a>Code language: HTML, XML (xml)

这会带来可访问性问题。

很容易想到用 aria-* 属性来弥补。至少我之前是这么想的。网上有不少资料推荐类似下面的结构:

<a href="#" aria-label="About">
  <span aria-hidden="true">
    <span>A</span><span>b</span><span>o</span><span>u</span><span>t</span>
  </span>
</a>Code language: HTML, XML (xml)

看起来没问题吧?不!这种结构依然很糟糕。实际上,网上能找到的大多数结构都有问题。我不是这个领域的专家,所以请教了一些人,发现 Adrian Roselli 的两篇博客很有参考价值:

强烈建议读一读,理解为什么把单词拆成字母是个坏主意(以及可能的替代方案)。

那我为什么还要做这个演示?

我更倾向于把它当作一次探索现代 CSS 特性的实验。这个效果里可能有很多你还不熟悉的属性,是了解它们的好机会。可以用在娱乐或 side project 中,但在广泛使用或关键场景中引入前,请三思。

好了,声明完毕,我们开始。

原理说明

思路是使用 offset() 属性,定义字母沿一条路径运动。这条路径是一条曲线,我们沿曲线做动画。offset() 是一个被低估的特性,但潜力很大,尤其配合现代 CSS 使用时。我曾用它做过无限跑马灯动画、让元素沿圆精确排布、做图片画廊等。

下面是一个简化示例,帮助理解我们要用的技巧:

CodePen Embed Fallback

上面的演示使用了来自 SVG 的 path() 值。三个字母最初沿第一条路径,悬停时切换到第二条路径。借助 transition,就形成了平滑的效果。

可惜的是,使用 SVG 并不理想,因为你只能创建静态、基于像素的路径,无法用 CSS 控制。因此我们将转而使用新的 shape() 函数,它可以定义复杂形状(包括曲线),并方便地用 CSS 控制。

本文只用到 shape() 的简单用法(只需要一条曲线),如果想深入了解这个强大函数,可以参考我之前的文章:

开始写代码

用到的 HTML:

<ul>
  <li>
    <a href="#"><span>A</span><span>b</span><span>o</span><span>u</span><span>t</span></a>
  </li>
  <!-- 更多 li 元素 -->
</ul>Code language: HTML, XML (xml)

CSS:

ul li a {
  display: flex;
  font-family: monospace;
}
ul li a span {
  offset-path: shape(???);
  offset-distance: ???;
}
ul li a:hover {
  offset-path: shape(???);
}Code language: CSS (css)

目前还比较朴素

CodePen Embed Fallback

用 flex 让字母并排,并用等宽字体,确保每个字母宽度一致。

接下来用下面的代码定义路径:

offset-path: shape(from Xa Ya, curve to Xb Yb with Xc Yc / Xd Yd );Code language: CSS (css)

这里用 curve 命令在 A 到 B 之间画贝塞尔曲线,控制点为 C 和 D。

然后通过调整控制点的坐标(尤其是 Y 值)来驱动曲线动画。当 Y 与 A、B 的 Y 相同时是直线;更大时变成曲线。

曲线的代码大致如下:

offset-path: shape(from Xa Y, curve to Xb Y with Xc Y1 / Xd Y1);

直线的代码如下:

offset-path: shape(from Xa Y, curve to Xb Y with Xc Y / Xd Y);

注意我们只改控制点的 Y,其他保持不变。

现在来确定各参数。使用 offset 时有两个要点:

  1. 默认以元素中心作为在路径上的位置。
  2. 定义在子元素上,但参考框是父容器。

第一个字母应在路径起点,最后一个在终点,所以 A 是第一个字母中心,B 是最后一个字母中心:

Y = 50%Xa = .5chXb = 100% - Xa = 100% - .5ch

C 和 D 的 X 没有固定规则,可以任意指定。我选 Xc = 30%Xd = 100% - Xc = 70%。你可以自己调整这些值试验不同的曲线形态。

路径现在可以这样写:

offset-path: shape(from .5ch 50%, curve to calc(100% - .5ch) 50% with 30% Y / 70% Y);

Y 是变量,可以是 50%(与 A、B 相同)或别的值,我们设成 50% - HH 越大,弹性越强。

试试看:

CodePen Embed Fallback

一团糟!因为我们没定义 offset-distance,所有字母都叠在一起了。

是不是要给每个字母单独设位置?那太麻烦了。

我们必须给每个字母不同的位置,好在可以用一个公式配合 sibling-index()sibling-count() 搞定。

第一个字母在 0%,最后一个在 100%。共 N 个字母,步长为 100%/(N - 1),字母从 0%100% 依次排布,公式为:

offset-distance: (100% * i)/(N - 1)

其中 i 从 0 开始。

写成 CSS:

offset-distance: calc(100%*(sibling-index() - 1)/(sibling-count() - 1))Code language: CSS (css)

CodePen Embed Fallback

几乎完美。除了最后一个字母外都位置正确。由于某种原因,0%100% 被当成同一个点。offset-distance 不限于 0%–100%,可以取任意值(包括负值),有一种取模行为形成环路。你可以从 0%100% 走完整条路径,到 100% 后又回到起点,还能继续从 100%200%,如此往复。

虽然有点反直觉,但修复很简单:把 100% 换成 99.9%。有点 hack,但有效!

CodePen Embed Fallback

现在排布完美了,悬停时可以看到直线变成曲线的过程。

最后加上 transition,就大功告成!

CodePen Embed Fallback

可能还不算完全搞定,因为动画似乎有些异常。这很可能是 bug(我已在此提交),不过问题不大,因为我本来就打算重构,避免重复写两次 shape,改为动画一个变量:

@property --_s {
  syntax: "<number>";
  initial-value: 0;
  inherits: true;
}
ul li a {
  --h: 20px; /* 控制效果强度 */
 
  display: flex;
  font: bold 40px monospace;
  transition: --_s .3s;
}
ul li a:hover {
  --_s: 1;
}
ul li a span {
  offset-path: 
    shape(
      from .5ch 50%, curve to calc(100% - .5ch) 50% 
      with 30% calc(50% - var(--_s)*var(--h)) / 70% calc(50% - var(--_s)*var(--h))
    );
  offset-distance: calc(99.9%*(sibling-index() - 1)/(sibling-count() - 1));
}Code language: CSS (css)

现在有了 --h 变量来调节路径曲率,以及一个内部变量在 0 到 1 之间动画,实现从直线到曲线的过渡。

CodePen Embed Fallback

嗒哒!动画完美了!但弹性感呢?

要得到弹性效果,需要调整缓动,用到 linear()。这是最简单的部分,我用生成器生成取值。

多调几次直到满意。我得到的是:

CodePen Embed Fallback

效果已经不错,但如果微调曲线还能更好。目前所有单词的曲线「高度」是一样的,理想情况是根据单词长度变化。为此我会在公式里加入 sibling-count(),让单词越宽时高度越大。

CodePen Embed Fallback

让效果具备方向感知

效果已经可用,但既然做到这里,不妨再进一步:根据鼠标方向决定曲线向上还是向下。

向上的曲线已经通过 --_s: 1 实现:

ul li a:hover {
  --_s: 1;
}Code language: CSS (css)

若改为 -1,就得到向下的曲线:

CodePen Embed Fallback

现在需要把两种情况结合起来。从上方悬停时,使用向下曲线 --_s: -1;从下方悬停时,使用向上曲线 --_s: 1

首先给 li 加一个伪元素,填满上半部分并位于链接上方:

ul li {
  position: relative;
}
ul li:after {
  content: "";
  position: absolute;
  inset: 0 0 50%;
  cursor: pointer;
}Code language: CSS (css)

CodePen Embed Fallback

然后定义两个不同的选择器。当悬停伪元素时,相当于也悬停了 li,所以可以用:

ul li:hover a {
  --_s: -1;
}Code language: CSS (css)

悬停 a 时,同样会悬停 li,上面的规则也会生效。但若悬停的是伪元素,则没有悬停 a,因此可以用:

ul li:has(a:hover) a {
  --_s: 1;
}Code language: CSS (css)

有点绕?没关系,我们把两个选择器放在一起看:

ul li:hover a {
  --_s: -1;
}
ul li:has(a:hover) a {
  --_s: 1;
}Code language: CSS (css)

我们可以从上方(通过伪元素)或从下方(通过 a)悬停。前者会触发第一个选择器,因为我们在悬停 li,但不会触发第二个,因为 li「并没有悬停其 a」。当我们悬停 a 时,两个选择器都会触发,后者会胜出。

方向感知就这么实现了!

CodePen Embed Fallback

能用,但不如开头的演示那么流畅。当鼠标移动穿过整个元素时,会突然停止一个动画并切换到另一个。

可以调整伪元素的大小来改善。悬停时让它覆盖整个元素,这样就不会再触达下方的 a,第二个动画就不会触发。而悬停 a 时,把伪元素高度设为 0,就无法悬停它,从而不会触发第一个动画。

CodePen Embed Fallback

好多了!把伪元素设为透明,效果就很自然。

CodePen Embed Fallback

小结

希望你喜欢这次 CSS 小实验。再提醒一次:在项目中投入使用前请三思。这是一个很好的 demos 来了解 shape()linear()sibling-index() 等现代特性,但为这类效果牺牲可访问性并不值得。

HTML&CSS&JS:基于定位的实时天气卡片

作者 前端Hardy
2026年2月27日 13:51

这个 HTML 页面实现了一个基于用户地理位置的实时天气信息卡片,界面美观、交互流畅。页面加载自动定位→请求天气数据→渲染卡片,支持手动刷新,全流程有加载 / 错误状态提示,动效过渡自然。赶快收藏学习吧。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

image

HTML&CSS

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>实时天气卡片</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            padding: 20px;
        }

        .weather-card {
            background: rgba(255, 255, 255, 0.95);
            border-radius: 20px;
            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
            padding: 24px;
            max-width: 280px;
            width: 100%;
            backdrop-filter: blur(10px);
            transition: transform 0.3s ease, box-shadow 0.3s ease;
        }

        .weather-card:hover {
            transform: translateY(-3px);
            box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
        }

        .location {
            text-align: center;
            margin-bottom: 16px;
        }

        .location-icon {
            font-size: 24px;
            margin-bottom: 6px;
        }

        .city-name {
            font-size: 20px;
            font-weight: 700;
            color: #333;
            margin-bottom: 4px;
        }

        .current-time {
            font-size: 12px;
            color: #888;
            font-weight: 500;
        }

        .weather-main {
            text-align: center;
            margin: 20px 0;
        }

        .weather-icon {
            font-size: 56px;
            margin-bottom: 8px;
        }

        .temperature {
            font-size: 42px;
            font-weight: 300;
            color: #333;
            line-height: 1;
        }

        .temperature span {
            font-size: 22px;
            vertical-align: top;
        }

        .weather-description {
            font-size: 16px;
            color: #666;
            margin-top: 6px;
            text-transform: capitalize;
            margin-right: 20px;
        }

        .weather-details {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 12px;
            margin-top: 20px;
            padding-top: 16px;
            border-top: 1px solid #f0f0f0;
        }

        .detail-item {
            text-align: center;
            padding: 8px 6px;
            background: #f8f9fa;
            border-radius: 10px;
            transition: background 0.3s ease;
        }

        .detail-item:hover {
            background: #e9ecef;
        }

        .detail-icon {
            font-size: 18px;
            margin-bottom: 4px;
        }

        .detail-value {
            font-size: 14px;
            font-weight: 600;
            color: #333;
            margin-bottom: 2px;
        }

        .detail-label {
            font-size: 10px;
            color: #999;
            text-transform: uppercase;
            letter-spacing: 0.5px;
        }

        .loading {
            text-align: center;
            color: #666;
            font-size: 14px;
        }

        .error {
            background: #fee;
            color: #c33;
            padding: 12px;
            border-radius: 10px;
            text-align: center;
            margin-top: 16px;
            font-size: 14px;
        }

        .refresh-btn {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            padding: 10px 24px;
            border-radius: 20px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            margin-top: 16px;
            width: 100%;
            transition: transform 0.2s ease, box-shadow 0.2s ease;
        }

        .refresh-btn:hover {
            transform: scale(1.02);
            box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
        }

        .refresh-btn:active {
            transform: scale(0.98);
        }
    </style>
</head>
<body>
    <div class="weather-card" id="weatherCard">
        <div class="location">
            <div class="location-icon">📍</div>
            <div class="city-name" id="cityName">获取位置中...</div>
            <div class="current-time" id="currentTime">--:--:--</div>
        </div>

        <div id="weatherContent">
            <div class="loading">正在获取天气信息...</div>
        </div>
    </div>

    <script>
        // 天气图标映射
        const weatherIcons = {
            '01d': '☀️', '01n': '🌙',
            '02d': '⛅', '02n': '☁️',
            '03d': '☁️', '03n': '☁️',
            '04d': '☁️', '04n': '☁️',
            '09d': '🌧️', '09n': '🌧️',
            '10d': '🌦️', '10n': '🌧️',
            '11d': '⛈️', '11n': '⛈️',
            '13d': '❄️', '13n': '❄️',
            '50d': '🌫️', '50n': '🌫️'
        };

        // 更新时间
        function updateTime() {
            const now = new Date();
            const options = {
                year: 'numeric',
                month: 'long',
                day: 'numeric',
                weekday: 'long',
                hour: '2-digit',
                minute: '2-digit',
                second: '2-digit',
                hour12: false
            };
            document.getElementById('currentTime').textContent = now.toLocaleDateString('zh-CN', options);
        }

        // 通过经纬度获取中文城市名称
        async function getChineseCityName(latitude, longitude) {
            try {
                // 使用免费的反向地理编码 API
                const response = await fetch(
                    `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${latitude}&longitude=${longitude}&localityLanguage=zh`
                );
                const data = await response.json();

                // 优先使用市级名称,如果没有则使用区级名称
                if (data.city) {
                    return data.city;
                } else if (data.locality) {
                    return data.locality;
                } else if (data.principalSubdivision) {
                    return data.principalSubdivision;
                }
                return null;
            } catch (error) {
                console.error('获取中文城市名称失败:', error);
                return null;
            }
        }

        // 获取位置和天气
        async function getWeather() {
            const weatherContent = document.getElementById('weatherContent');
            const cityName = document.getElementById('cityName');

            if (!navigator.geolocation) {
                weatherContent.innerHTML = '<div class="error">您的浏览器不支持地理位置定位</div>';
                return;
            }

            try {
                // 获取地理位置
                const position = await new Promise((resolve, reject) => {
                    navigator.geolocation.getCurrentPosition(resolve, reject, {
                        enableHighAccuracy: true,
                        timeout: 10000,
                        maximumAge: 0
                    });
                });

                const { latitude, longitude } = position.coords;

                // 获取中文城市名称
                const chineseCity = await getChineseCityName(latitude, longitude);
                if (chineseCity) {
                    cityName.textContent = chineseCity;
                } else {
                    cityName.textContent = '获取位置中...';
                }

                // 调用 OpenWeatherMap API(使用免费的 API key,实际使用时需要替换)
                const apiKey = '4d8fb5b93d4af21d66a2948710284366'; // 这是一个公开的 demo key
                const response = await fetch(
                    `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&appid=${apiKey}&units=metric&lang=zh_cn`
                );

                if (!response.ok) {
                    throw new Error('获取天气信息失败');
                }

                const data = await response.json();

                // 显示天气信息(传入中文城市名称)
                displayWeather(data, chineseCity);

            } catch (error) {
                console.error('Error:', error);
                weatherContent.innerHTML = `
                    <div class="error">
                        ${error.message || '无法获取天气信息,请检查网络连接'}
                    </div>
                    <button class="refresh-btn" onclick="getWeather()">重试</button>
                `;
            }
        }

        // 显示天气信息
        function displayWeather(data, chineseCityName) {
            const weatherContent = document.getElementById('weatherContent');
            const cityName = document.getElementById('cityName');

            // 更新城市名称(优先使用中文城市名称)
            if (chineseCityName) {
                cityName.textContent = chineseCityName;
            } else if (data.name) {
                cityName.textContent = data.name;
            } else {
                cityName.textContent = '当前位置';
            }

            const iconCode = data.weather[0].icon;
            const icon = weatherIcons[iconCode] || '🌤️';

            weatherContent.innerHTML = `
                <div class="weather-main">
                    <div class="weather-icon">${icon}</div>
                    <div class="temperature">
                        ${Math.round(data.main.temp)}<span>°C</span>
                    </div>
                    <div class="weather-description">
                        ${data.weather[0].description || '未知天气'}
                    </div>
                </div>

                <div class="weather-details">
                    <div class="detail-item">
                        <div class="detail-icon">💧</div>
                        <div class="detail-value">${data.main.humidity}%</div>
                        <div class="detail-label">湿度</div>
                    </div>
                    <div class="detail-item">
                        <div class="detail-icon">💨</div>
                        <div class="detail-value">${Math.round(data.wind.speed)} m/s</div>
                        <div class="detail-label">风速</div>
                    </div>
                    <div class="detail-item">
                        <div class="detail-icon">🌡️</div>
                        <div class="detail-value">${Math.round(data.main.feels_like)}°C</div>
                        <div class="detail-label">体感</div>
                    </div>
                </div>

                <button class="refresh-btn" onclick="getWeather()">🔄 刷新天气</button>
            `;
        }

        // 初始化
        document.addEventListener('DOMContentLoaded', () => {
            updateTime();
            setInterval(updateTime, 1000); // 每秒更新时间
            getWeather(); // 获取天气
        });
    </script>
</body>
</html>

HTML

  • div weather-card weatherCard:容器标签。天气卡片核心容器。毛玻璃效果 + 圆角 + 阴影,hover 时有上浮动效
  • div location:容器标签。位置 / 时间展示区。包含定位图标、城市名称、当前时间
  • div weatherContent:容器标签。天气内容动态展示区 初始显示「加载中」,后续通过 JS 替换为天气数据
  • div loading:容器标签。加载状态提示 初始显示「正在获取天气信息...」
  • div error:容器标签。错误提示容器 定位 / 接口失败时显示错误信息
  • button refresh-btn:按钮标签。刷新天气按钮。点击触发重新获取天气数据,有 hover/active 动效
  • script:脚本标签。内嵌 JavaScript。实现定位、接口请求、数据渲染、时间更新等动态逻辑

CSS

1. 全局重置与基础布局

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box; /* 统一盒模型,避免 padding 撑大元素 */
}
body {
  min-height: 100vh; /* 占满屏幕高度 */
  display: flex;
  justify-content: center;
  align-items: center; /* 卡片垂直水平居中 */
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); /* 渐变背景 */
  font-family: 'Segoe UI', sans-serif; /* 现代无衬线字体 */
  padding: 20px; /* 移动端留白,避免卡片贴边 */
}

核心:全局重置默认边距,弹性布局实现卡片居中,渐变背景提升视觉质感。

2. 天气卡片核心样式

.weather-card {
  background: rgba(255, 255, 255, 0.95); /* 半透明白色 */
  border-radius: 20px; /* 大圆角提升现代感 */
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); /* 柔和阴影 */
  padding: 24px;
  max-width: 280px; /* 固定最大宽度,适配移动端 */
  width: 100%;
  backdrop-filter: blur(10px); /* 毛玻璃核心效果 */
  transition: transform 0.3s ease, box-shadow 0.3s ease; /* 过渡动效 */
}
.weather-card:hover {
  transform: translateY(-3px); /* 鼠标悬浮上浮 */
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); /* 阴影放大 */
}

核心:backdrop-filter: blur(10px) 实现毛玻璃效果,hover 上浮 + 阴影增强交互反馈,固定最大宽度适配移动端。

3. 天气数据布局样式

/* 天气主信息区(温度/图标/描述) */
.weather-main {
  text-align: center;
  margin: 20px 0;
}
.temperature {
  font-size: 42px;
  font-weight: 300; /* 轻量级字体,更现代 */
  line-height: 1; /* 消除行高冗余 */
}
.temperature span {
  font-size: 22px;
  vertical-align: top; /* 摄氏度符号上对齐 */
}

/* 天气详情网格(湿度/风速/体感) */
.weather-details {
  display: grid;
  grid-template-columns: repeat(3, 1fr); /* 三等分网格 */
  gap: 12px;
  border-top: 1px solid #f0f0f0; /* 分隔线 */
  padding-top: 16px;
}
.detail-item {
  text-align: center;
  background: #f8f9fa; /* 浅灰背景 */
  border-radius: 10px;
  padding: 8px 6px;
  transition: background 0.3s ease;
}
.detail-item:hover {
  background: #e9ecef; /* hover 加深背景 */
}

核心:网格布局实现「湿度 / 风速 / 体感」三等分展示,轻量级字体 + 对齐优化提升温度显示的视觉层次,细节项 hover 背景变化增强交互。

4. 按钮与状态样式

/* 刷新按钮 */
.refresh-btn {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); /* 与页面背景呼应 */
  color: white;
  border: none;
  padding: 10px 24px;
  border-radius: 20px; /* 胶囊按钮 */
  width: 100%; /* 全屏宽按钮,适配移动端点击 */
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.refresh-btn:hover {
  transform: scale(1.02); /* 轻微放大 */
  box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); /* 发光阴影 */
}
.refresh-btn:active {
  transform: scale(0.98); /* 点击下压 */
}

/* 加载/错误状态 */
.loading {
  text-align: center;
  color: #666;
}
.error {
  background: #fee; /* 浅红背景 */
  color: #c33; /* 深红文字 */
  padding: 12px;
  border-radius: 10px;
  text-align: center;
}

核心:按钮渐变背景与页面呼应,hover 放大 + 发光阴影,active 下压模拟物理按钮反馈;错误状态用红系配色提示,加载状态居中显示,视觉层级清晰。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Tailwind CSS v4 — 当框架猜不透你的心思

作者 parade岁月
2026年2月27日 17:14

你在项目里写下 text-(--brand-color),满心期待文字变成品牌色,刷新页面——字号变了。

颜色没变,字号倒是歪了。你盯着屏幕,开始怀疑人生。

别急,这不是 bug,是 Tailwind 在"猜"你的意图——而且猜错了。

这篇文章会带你走一遍真实的开发场景。从最基础的任意值用法开始,一步步遇到更复杂的情况,直到你理解 Tailwind 为什么会猜错,以及如何优雅地纠正它。


场景一:设计稿给了个非标准值

设计师甩过来一张稿子,标注写着:top: 117px、背景色 #bada55

你翻了一遍 Tailwind 的间距和颜色系统——没有。top-28112pxtop-32128px,不上不下。

这时候就需要任意值(Arbitrary Values)了。用方括号 [] 把具体的 CSS 值包起来:

<div class="top-[117px]">精确定位</div>

<button class="bg-[#bada55]">这个颜色名字挺快乐</button>

<div class="left-[calc(50%-4rem)]">居中偏移</div>

方括号里可以放任何合法的 CSS 值——像素、百分比、calc() 表达式,甚至 var()。Tailwind 会原封不动地把它编译成对应的 CSS。

CSS 变量怎么写?

如果你的值存在 CSS 变量里,v4 提供了一个更简洁的语法——用圆括号 () 代替方括号:

<!-- v4 新语法:圆括号 + 裸变量名 -->
<div class="bg-(--brand-color)">用 CSS 变量设背景色</div>

<!-- 当然,显式写 var() 依然有效 -->
<div class="bg-[var(--brand-color)]">效果一样</div>

这是 v4 相对 v3 的一个重要变化。v3 里 CSS 变量简写用的是方括号 bg-[--brand-color],v4 改成了圆括号 bg-(--brand-color)。这个改动不是为了好看——而是为了解决歧义问题,后面会详细说。


场景二:Tailwind 没有的 CSS 属性

项目里需要用 mask-type 控制 SVG 遮罩行为。你搜了一圈文档,Tailwind 没有提供这个工具类。

任意属性(Arbitrary Properties)登场。用方括号把完整的 属性:值 对写进去:

<div class="[mask-type:luminance]">
  SVG 遮罩使用亮度模式
</div>

它和修饰符(modifier)配合也没问题:

<div class="[mask-type:luminance] hover:[mask-type:alpha]">
  hover 时切换为 alpha 模式
</div>

用任意属性设置 CSS 变量

这个语法还有一个很实用的场景——在 HTML 里直接设置 CSS 变量的值:

<div class="[--scroll-offset:56px] lg:[--scroll-offset:44px]">
  不同断点下设置不同的滚动偏移量
</div>

配合响应式前缀,你可以把 CSS 变量当作"响应式参数"来用,而不用写额外的媒体查询。


场景三:选择器玩不转了

产品经理说:"列表前三项要加下划线,hover 的时候。"

:nth-child(-n+3):hover —— 这选择器 Tailwind 的内置修饰符肯定不够用。

任意变体(Arbitrary Variants)可以搞定:

<ul>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 1 项</li>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 2 项</li>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 3 项</li>
  <li>第 4 项(不受影响)</li>
</ul>

方括号里的 & 代表当前元素。Tailwind 会把 & 替换成生成的类名,编译出你需要的选择器。

再来几个例子:

<!-- 所有子 p 元素加上 margin-top -->
<div class="[&_p]:mt-4">
  <p>我有 margin-top</p>
  <p>我也有</p>
</div>

<!-- 当元素有 .is-dragging 类时 -->
<li class="[&.is-dragging]:cursor-grabbing">
  拖拽中换光标
</li>

<!-- @supports 查询 -->
<div class="flex [@supports(display:grid)]:grid">
  支持 grid 就用 grid,否则用 flex
</div>

v4 变体堆叠顺序变了:从左往右读,和 CSS 选择器一致。v3 是从右往左。


场景四:值里面有空格怎么办?

你在写 Grid 布局,需要 grid-template-columns: 1fr 500px 2fr

直接写 grid-cols-[1fr 500px 2fr]?Tailwind 会把空格当作类名分隔符,直接报错。

解决方案:用下划线代替空格。

<div class="grid grid-cols-[1fr_500px_2fr]">
  <!-- 编译后:grid-template-columns: 1fr 500px 2fr -->
</div>

Tailwind 在编译时会自动把下划线转成空格。

但是 URL 里的下划线怎么办?

放心,Tailwind 足够聪明,会保留 URL 里的下划线:

<div class="bg-[url('/what_a_rush.png')]">
  <!-- 不会被转成空格,保持原样 -->
</div>

真的需要下划线呢?

用反斜杠转义:

<div class="before:content-['hello_world']">
  <!-- 编译后:content: 'hello_world' -->
</div>

JSX 里反斜杠被吃了?

JSX 的字符串会把 `` 当转义字符处理。用 String.raw 模板标签:

<div className={String.raw`before:content-['hello_world']`}>
  在 JSX 中安全地使用下划线
</div>

核心场景:Tailwind 猜错了

好,前面都是热身。现在进入本文的重头戏。

问题复现

回到开头的例子。你在 CSS 里定义了一个品牌色变量:

:root {
  --brand-color: #e63946;
}

然后你写下:

<p class="text-(--brand-color)">品牌色文字</p>

你期望的是文字变成红色。但实际效果是——字号变了,颜色没变。

为什么?

因为 text-* 在 Tailwind 里是一个多义命名空间。它同时映射了两种不同的 CSS 属性:

  • text-lgtext-smfont-size(字号)
  • text-red-500text-blackcolor(颜色)

当你写字面值的时候,Tailwind 能从值本身推断出类型:

<!-- Tailwind 看到 22px,推断为 length → font-size -->
<div class="text-[22px]">这是字号</div>

<!-- Tailwind 看到 #bada55,推断为 color → color -->
<div class="text-[#bada55]">这是颜色</div>

22px 明显是长度,#bada55 明显是颜色——推断没问题。

但 CSS 变量是个黑盒

当你写 text-(--brand-color) 的时候,Tailwind 看不到变量里存的是什么。它不知道 --brand-color 是颜色还是尺寸还是别的什么。

这时候 Tailwind 只能猜。而默认的猜测策略可能不符合你的预期——它可能把变量当成了 font-size 而不是 color

于是你的文字不是变红了,而是字号变成了 var(--brand-color),浏览器无法解析为有效字号,表现就很诡异。

解决方案:CSS 数据类型提示

在圆括号里,变量名前面加上类型提示

<!-- 明确告诉 Tailwind:这是颜色 -->
<p class="text-(color:--brand-color)">品牌色文字 ✓</p>

<!-- 明确告诉 Tailwind:这是字号 -->
<p class="text-(length:--font-size)">自定义字号 ✓</p>

语法格式:工具类-(类型:--变量名)

Tailwind 看到 color: 前缀,就知道应该把这个变量编译成 color 属性而不是 font-size。歧义消除。

方括号里的写法

如果你用 var() 的显式写法,类型提示放在方括号开头:

<p class="text-[color:var(--brand-color)]">同样有效</p>

不止 text-*

text-* 是最经典的歧义案例,但不是唯一一个。以下工具类都存在类似的命名空间冲突:

bg-* — 背景相关

<!-- 背景色 -->
<div class="bg-(color:--my-var)">背景颜色</div>

<!-- 背景图 -->
<div class="bg-(image:--my-var)">背景图片</div>

<!-- 背景位置 -->
<div class="bg-(position:--my-var)">背景位置</div>

bg-* 的歧义更多——它可以是颜色、图片、尺寸、位置,不加类型提示几乎必出问题。

border-* — 边框相关

<!-- 边框颜色 -->
<div class="border-(color:--my-var)">边框颜色</div>

<!-- 边框宽度 -->
<div class="border-(length:--my-var)">边框宽度</div>

shadow-* — 阴影相关

<div class="shadow-(color:--my-var)">阴影颜色</div>

decoration-* — 文本装饰

<!-- 装饰线颜色 -->
<div class="decoration-(color:--my-var)">装饰色</div>

<!-- 装饰线粗细 -->
<div class="decoration-(length:--my-var)">装饰粗细</div>

规律总结:只要一个工具类前缀同时对应多种 CSS 属性(颜色 + 尺寸最常见),用 CSS 变量时就需要类型提示。用字面值(如 #fff2px)时不需要,因为 Tailwind 能自动推断。


可用的类型提示一览

Tailwind v4 支持的 CSS 数据类型提示:

类型关键词 匹配什么 示例值
color CSS 颜色 #fffrgb(...)oklch(...)
length 长度 16px1rem2em
percentage 百分比 50%
number 数值 1.50
integer 整数 14
angle 角度 45deg0.25turn
url URL url(...)
image CSS 图片类型 url(...)linear-gradient(...)
position 位置 centertop left
ratio 比例 16/9
line-width 线宽 边框宽度值
bg-size 背景尺寸 covercontain
family-name 字体族名 字体名称

速查表

把全文涉及的语法整理在一起,方便随时翻阅:

场景 语法 示例
字面任意值 工具类-[值] top-[117px]bg-[#bada55]
CSS 变量简写 工具类-(--变量) bg-(--brand-color)
CSS 变量 + var() 工具类-[var(--变量)] bg-[var(--brand-color)]
类型提示(圆括号) 工具类-(类型:--变量) text-(color:--brand-color)
类型提示(方括号) 工具类-[类型:var(--变量)] text-[color:var(--brand-color)]
任意属性 [属性:值] [mask-type:luminance]
设置 CSS 变量 [--变量:值] [--scroll-offset:56px]
任意变体 [选择器]:工具类 [&:nth-child(3)]:underline
空格用下划线 _ 代替空格 grid-cols-[1fr_500px_2fr]
真正的下划线 _ 转义 content-['hello_world']
JSX 中的转义 String.raw`...` String.raw`content-['a_b']`

5 个让 CSS 起飞的新特性,设计师看了直呼内行

2026年2月27日 11:01

有大佬说: "CSS 而已,能玩出什么花?"

今天我就用 5 个原生 CSS 新特性告诉你——现在的 CSS,已经不是当年的 CSS 了。它不再是那个只会改背景颜色的"样式表",而是进化成了能处理逻辑、响应状态、甚至做动画的系统级设计工具

设计师想在 Figma 里做的效果,CSS 现在不仅能做,而且做得更好。往下看,每一个都能让你删掉一坨 JavaScript 代码。


1. Scroll State Queries:终于知道"粘性元素"什么时候粘住了

以前我们想给 sticky 导航栏加个阴影,怎么做?监听 scroll 事件,计算滚动距离,判断元素是否"粘住"……一堆性能杀手代码

现在?一行 CSS 搞定

css

.sticky-nav {
  container-type: scroll-state;
  position: sticky;
  top: 0;
}

.sticky-nav > nav {
  transition: box-shadow 0.3s;
  
  /* 只有当元素真正"粘住"时,才加阴影 */
  @container scroll-state(stuck: top) {
    box-shadow: 0 4px 20px rgba(0,0,0,0.1);
  }
}

这意味着什么?

  • 不用写 Intersection Observer
  • 不用监听 scroll 事件
  • 浏览器原生告诉你"我粘住了"

这个 API 还能检测"是否被滚动捕捉"、"是否可滚动"等状态。Snap 轮播图的激活态?一行代码的事

设计师惊呼:  "终于不用跟开发解释'当导航栏粘住时加阴影'是什么意思了。"


2. 完全自定义的 Select 下拉框:UI 库的末日

有个笑话:前端开发一辈子都在跟 select 标签较劲。为了让它长得好看,我们引过 Chosen、Select2、React Select……一个下拉框,几百 KB 的 JS

现在,原生 select 终于可以随便改了

css

/* 开启可自定义模式 */
select, ::picker(select) {
  appearance: base-select;
}

/* 选项里甚至可以放图片 */
option {
  display: flex;
  align-items: center;
  gap: 8px;
}

option img {
  width: 24px;
  height: 24px;
  border-radius: 50%;
}

对应的 HTML 长这样:

html

<select>
  <button>
    <selectedcontent></selectedcontent>
    <span class="arrow">👇</span>
  </button>
  <option>
    <img src="avatar1.jpg"> 张三
  </option>
  <option>
    <img src="avatar2.jpg"> 李四
  </option>
</select>

这是什么概念?

  • 下拉箭头可以随便改
  • 选项里可以放任何 HTML
  • 选中的内容可以自定义渲染
  • 完全不需要 JavaScript

设计师惊呼:  "所以以后 Figma 里的下拉框设计,都能 1:1 还原了?"


3. @starting-style:弹窗进出动画,终于丝滑了

以前做弹窗动画有个痛点:元素从 display: none 到显示,过渡效果不生效。因为没有"之前的状态"可以过渡。

@starting-style 专门解决这个问题

css

[popover] {
  /* 默认状态 */
  opacity: 0;
  transform: scale(0.9);
  transition: opacity 0.3s, transform 0.3s;
}

[popover]:popover-open {
  /* 打开后的状态 */
  opacity: 1;
  transform: scale(1);
}

/* 定义"开始动画前的状态" */
@starting-style {
  [popover]:popover-open {
    opacity: 0;
    transform: scale(0.9);
  }
}

就这么简单,弹窗出现时自动从 0 到 1,关闭时自动从 1 到 0。连 backdrop(背景遮罩)都可以一起动画

这意味着什么?

  • 再也不用 JS 控制入场动画
  • display: none 和 display: block 之间的过渡终于完美
  • Popover 和 Dialog 弹窗,天生就有丝滑动画

设计师惊呼:  "所以之前开发说的'弹窗动画不好做',是骗我的?"


4. contrast-color() 函数:自动适配文本颜色,再也不用写 JS 判断

设计师给了一个按钮,背景色是动态的(可能来自用户设置,可能来自数据)。问题来了:背景色深的时候,文字要用白色;背景色浅的时候,文字要用黑色

以前怎么做?JS 计算亮度,然后动态加 class。现在:

css

.button {
  --bg-color: #0066cc;  /* 可以是任何颜色 */
  background-color: var(--bg-color);
  
  /* 自动选择黑色或白色,保证可读性 */
  color: contrast-color(var(--bg-color));
}

contrast-color() 函数自动计算最佳对比色(黑或白),保证 WCAG 标准

更高级的用法:

css

.button {
  /* 指定两个候选色,让函数选择对比度更高的那个 */
  color: contrast-color(var(--bg-color), vs, #333, #eee);
}

这意味着什么?

  • 主题切换再也不用写两套文字颜色
  • 用户自定义主题时,样式自动适配
  • 再也不用为了文字可读性写 JS

设计师惊呼:  "所以以后设计系统里的文本颜色,可以自动适配背景了?"


5. Scroll-driven Animations:滚动即动画,性能炸裂

以前做滚动进度条、视差效果、滚动触发动画,都得靠 JS + requestAnimationFrame,性能消耗大,而且容易卡顿。

现在,CSS 原生支持动画进度绑定滚动位置

css

/* 一个简单的滚动进度条 */
#progress {
  height: 4px;
  background: #0066cc;
  
  /* 动画进度绑定滚动位置 */
  animation: grow-progress linear forwards;
  animation-timeline: scroll();
}

@keyframes grow-progress {
  from { width: 0%; }
  to { width: 100%; }
}

想要更复杂的视差效果?

css

.parallax-image {
  /* 滚动时,图片从 0.5 倍缩放到 1 倍 */
  animation: scale-image linear forwards;
  animation-timeline: scroll();
  animation-range: entry 0% exit 100%;
}

@keyframes scale-image {
  from { transform: scale(0.5); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}

这意味着什么?

  • 滚动进度条:3 行 CSS
  • 视差滚动:5 行 CSS
  • 元素随滚动淡入淡出:4 行 CSS
  • 完全不需要 JS,60fps 稳稳的

设计师惊呼:  "所以之前做的那个滚动交互动效,现在不用等开发排期了?"


写在最后

这 5 个特性只是冰山一角。现在的 CSS 已经有了:

  • 条件逻辑:if() 函数
  • 自定义函数:@function
  • 锚点定位:真正的绝对定位
  • 容器查询:组件内响应式
  • 嵌套语法:再也不用写重复的选择器

CSS 已经不是当初那个 CSS 了。

以前我们说"能用 CSS 解决的问题,就不要用 JS"。现在可以改成: "能用 CSS 解决的问题,都不叫问题。"

设计师和开发的鸿沟,正在被现代 CSS 一点点填平。你设计的每一个细节,现在都能用几行样式代码完美还原。


如果这篇文章让你对 CSS 刮目相看,点个赞,转个发,让更多朋友看到——CSS 起飞了。

评论区告诉我:你最想用哪个特性?或者你还见过哪些让你惊呼的 CSS 新功能?

HTML&CSS:纯CSS实现随机转盘抽奖机——无JS,全靠现代CSS黑科技!

作者 前端Hardy
2026年2月27日 10:30

这个 HTML 页面实现了一个交互式转盘抽奖效果,使用了现代 CSS 的一些实验性特性 (如 random() 函数、@layer、sibling-index() 等),并结合 SVG 图标和渐变背景,营造出一个视觉吸引、功能完整的“幸运大转盘”界面。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

演示效果

HTML&CSS

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS随机函数实现转盘效果</title>
    <style>
        @import url(https://fonts.bunny.net/css?family=jura:300,700);
        @layer base, notes, demo;

        @layer demo {
            :root {
                --items: 12;
                --spin-easing: cubic-bezier(0, 0.061, 0, 1.032);
                --slice-angle: calc(360deg / var(--items));
                --start-angle: calc(var(--slice-angle) / 2);

                --wheel-radius: min(40vw, 300px);
                --wheel-size: calc(var(--wheel-radius) * 2);
                --wheel-padding: 10%;
                --item-radius: calc(var(--wheel-radius) - var(--wheel-padding));

                --wheel-bg-1: oklch(0.80 0.16 30);
                --wheel-bg-2: oklch(0.74 0.16 140);
                --wheel-bg-3: oklch(0.80 0.16 240);
                --wheel-bg-4: oklch(0.74 0.16 320);

                --marker-bg-color: black;
                --button-text-color: white;
                --spin-duration: random(1s, 3s);

                --random-angle: random(1200deg, 4800deg, by var(--slice-angle));

                @supports not (rotate: random(1deg, 10deg)) {
                    --spin-duration: 2s;
                    --random-angle: 4800deg;
                }
            }


            .wrapper {
                position: relative;
                inset: 0;
                margin: auto;
                width: var(--wheel-size);
                aspect-ratio: 1;

                input[type=checkbox] {
                    position: absolute;
                    opacity: 0;
                    width: 1px;
                    height: 1px;
                    pointer-events: none;
                }

                &:has(input[type=checkbox]:checked) {
                    --spin-it: 1;
                    --btn-spin-scale: 0;
                    --btn-spin-event: none;
                    --btn-spin-trans-duration: var(--spin-duration);
                    --btn-reset-scale: 1;
                    --btn-reset-event: auto;
                    --btn-reset-trans-delay: var(--spin-duration);
                }

                .controls {
                    position: absolute;
                    z-index: 2;
                    inset: 0;
                    margin: auto;
                    width: min(100px, 10vw);
                    aspect-ratio: 1;
                    background: var(--marker-bg-color);
                    border-radius: 9in;
                    transition: scale 150ms ease-in-out;

                    &:has(:hover, :focus-visible) label {
                        scale: 1.2;
                        rotate: 20deg;
                    }

                    &::before {
                        content: '';
                        position: absolute;
                        top: 0;
                        left: 50%;
                        translate: -50% -50%;
                        width: 20%;
                        aspect-radio: 2/10;
                        background-color: transparent;
                        border: 2vw solid var(--marker-bg-color);
                        border-bottom-width: 4vw;
                        border-top: 0;
                        border-left-color: transparent;
                        border-right-color: transparent;
                        z-index: -1;
                    }

                    label {
                        cursor: pointer;
                        display: grid;
                        place-items: center;
                        width: 100%;
                        aspect-ratio: 1;
                        color: var(--button-text-color);
                        transition:
                            rotate 150ms ease-in-out,
                            scale 150ms ease-in-out;

                        svg {
                            grid-area: 1/1;
                            width: 50%;
                            height: 50%;
                            transition-property: scale;
                            transition-timing-function: ease-in-out;

                            &:first-child {
                                transition-duration: var(--btn-spin-trans-duration, 150ms);
                                scale: var(--btn-spin-scale, 1);
                                pointer-events: var(--btn-spin-event, auto);
                            }

                            &:last-child {
                                transition-duration: 150ms;
                                transition-delay: var(--btn-reset-trans-delay, 0ms);
                                scale: var(--btn-reset-scale, 0);
                                pointer-events: var(--btn-reset-event, none);
                            }
                        }
                    }


                }

                &:has(input[type=checkbox]:checked)>.wheel {
                    animation: --spin-wheel var(--spin-duration, 3s) var(--spin-easing, ease-in-out) forwards;
                }

                .wheel {
                    position: absolute;
                    inset: 0;
                    border-radius: 99vw;
                    border: 1px solid white;
                    user-select: none;
                    font-size: 24px;
                    font-weight: 600;
                    background: repeating-conic-gradient(from var(--start-angle),
                            var(--wheel-bg-1) 0deg var(--slice-angle),
                            var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle) * 2),
                            var(--wheel-bg-3) calc(var(--slice-angle) * 2) calc(var(--slice-angle) * 3),
                            var(--wheel-bg-4) calc(var(--slice-angle) * 3) calc(var(--slice-angle) * 4));

                    >span {
                        --i: sibling-index();

                        @supports not (sibling-index(0)) {
                            &:nth-child(1) {
                                --i: 1;
                            }

                            &:nth-child(2) {
                                --i: 2;
                            }

                            &:nth-child(3) {
                                --i: 3;
                            }

                            &:nth-child(4) {
                                --i: 4;
                            }

                            &:nth-child(5) {
                                --i: 5;
                            }

                            &:nth-child(6) {
                                --i: 6;
                            }

                            &:nth-child(7) {
                                --i: 7;
                            }

                            &:nth-child(8) {
                                --i: 8;
                            }

                            &:nth-child(9) {
                                --i: 9;
                            }

                            &:nth-child(10) {
                                --i: 10;
                            }

                            &:nth-child(11) {
                                --i: 11;
                            }

                            &:nth-child(12) {
                                --i: 12;
                            }
                        }
                        position: absolute;
                        offset-path: circle(var(--item-radius) at 50% 50%);
                        offset-distance: calc(var(--i) / var(--items) * 100%);
                        offset-rotate: auto;
                    }
                }
            }

            @keyframes --spin-wheel {
                to {
                    rotate: var(--random-angle);
                }
            }
        }

        @layer notes {
            section.notes {
                margin: auto;
                width: min(80vw, 56ch);

                p {
                    text-wrap: pretty;
                }

                > :first-child {
                    color: red;
                    background: rgb(255, 100, 103);
                    padding: .5em;
                    color: white;

                    @supports (rotate: random(1deg, 10deg)) {
                        display: none;
                    }
                }
            }
        }

        @layer base {

            *,
            ::before,
            ::after {
                box-sizing: border-box;
            }

            :root {
                color-scheme: light dark;
                --bg-dark: rgb(21 21 21);
                --bg-light: rgb(248, 244, 238);
                --txt-light: rgb(10, 10, 10);
                --txt-dark: rgb(245, 245, 245);
                --line-light: rgba(0 0 0 / .75);
                --line-dark: rgba(255 255 255 / .25);
                --clr-bg: light-dark(var(--bg-light), var(--bg-dark));
                --clr-txt: light-dark(var(--txt-light), var(--txt-dark));
                --clr-lines: light-dark(var(--line-light), var(--line-dark));
            }

            body {
                background-color: var(--clr-bg);
                color: var(--clr-txt);
                min-height: 100svh;
                margin: 0;
                padding: 2rem;
                font-family: "Jura", sans-serif;
                font-size: 1rem;
                line-height: 1.5;
                display: grid;
                place-content: center;
                gap: 2rem;
            }

            strong {
                font-weight: 700;
            }
        }
    </style>
</head>

<body>

    <section class="wrapper">
        <input type="checkbox" id="radio-spin">
        <div class="controls">
            <label for="radio-spin">
                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Spin the Wheel" title="Spin the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M14 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
                    <path d="M12 21c-3.314 0 -6 -2.462 -6 -5.5s2.686 -5.5 6 -5.5" />
                    <path d="M21 12c0 3.314 -2.462 6 -5.5 6s-5.5 -2.686 -5.5 -6" />
                    <path d="M12 14c3.314 0 6 -2.462 6 -5.5s-2.686 -5.5 -6 -5.5" />
                    <path d="M14 12c0 -3.314 -2.462 -6 -5.5 -6s-5.5 2.686 -5.5 6" />
                </svg>

                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Reset the Wheel" title="Reset the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M3.06 13a9 9 0 1 0 .49 -4.087" />
                    <path d="M3 4.001v5h5" />
                    <path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
                </svg>
            </label>
        </div>

        <div id="wheel" class="wheel">
            <span>乔丹</span>
            <span>詹姆斯</span>
            <span>布莱恩特</span>
            <span>约翰逊</span>
            <span>库里</span>
            <span>奥尼尔</span>
            <span>邓肯</span>
            <span>贾巴尔</span>
            <span>杜兰特</span>
            <span>哈登</span>
            <span>字母哥</span>
            <span>伦纳德</span>
        </div>
    </section>
</body>

</html>

HTML

  • section:转盘核心容器(语义化区块)相对定位,包含转盘所有子元素
  • input:转盘触发开关(核心交互控件) 视觉隐藏(opacity:0),通过「选中 / 未选中」触发动画
  • div controls:转盘中心按钮容器。绝对定位,层级高于转盘,包含点击触发的 label
  • label :绑定隐藏复选框,作为可点击按钮。点击该标签等价于点击复选框,触发状态切换
  • svg:显示「旋转」「重置」图标。两个 SVG 重叠,通过 CSS 控制显隐
  • div wheel:转盘本体。圆形布局,包含 12 个奖项文本
  • span:转盘奖项文本(12 个 NBA 球星名称) 每个 span 对应转盘一个分区,通过 CSS 定位到圆形轨道

CSS

1. 样式分层管理(@layer)

@layer base, notes, demo;
  • base:全局基础样式(盒模型、明暗色模式、页面布局),优先级最低;
  • notes:兼容提示文本样式,优先级中等;
  • demo:转盘核心样式(尺寸、动画、交互),优先级最高;

作用:按层级管理样式,避免样式冲突,便于维护。

2. 核心变量定义(:root)

:root {
  --items: 12; /* 转盘分区数量 */
  --slice-angle: calc(360deg / var(--items)); /* 每个分区角度(30°) */
  --wheel-radius: min(40vw, 300px); /* 转盘半径(自适应,最大300px) */
  --spin-duration: random(1s, 3s); /* 随机旋转时长(1-3秒) */
  --random-angle: random(1200deg, 4800deg, by var(--slice-angle)); /* 随机旋转角度(步长30°) */
  /* 浏览器兼容降级:不支持random()则固定值 */
  @supports not (rotate: random(1deg, 10deg)) {
    --spin-duration: 2s;
    --random-angle: 4800deg;
  }
}

核心:用变量统一管理转盘尺寸、角度、动画参数,random() 实现「随机旋转」核心效果,同时做浏览器兼容降级。

3. 转盘交互触发逻辑

/* 监听复选框选中状态,更新变量控制图标/动画 */
.wrapper:has(input[type=checkbox]:checked) {
  --btn-spin-scale: 0; /* 隐藏旋转图标 */
  --btn-reset-scale: 1; /* 显示重置图标 */
}
/* 选中时触发转盘旋转动画 */
.wrapper:has(input[type=checkbox]:checked)>.wheel {
  animation: --spin-wheel var(--spin-duration) var(--spin-easing) forwards;
}
/* 旋转动画:转到随机角度后保持状态 */
@keyframes --spin-wheel {
  to { rotate: var(--random-angle); }
}

核心:通过 :has() 伪类监听复选框状态,触发转盘动画,forwards 确保动画结束后不回弹。

4. 转盘视觉与布局

.wheel {
  border-radius: 99vw; /* 圆形转盘 */
  /* 四色循环锥形渐变,实现转盘分区背景 */
  background: repeating-conic-gradient(from var(--start-angle),
    var(--wheel-bg-1) 0deg var(--slice-angle),
    var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle)*2),
    var(--wheel-bg-3) calc(var(--slice-angle)*2) calc(var(--slice-angle)*3),
    var(--wheel-bg-4) calc(var(--slice-angle)*3) calc(var(--slice-angle)*4));
  >span {
    offset-path: circle(var(--item-radius) at 50% 50%); /* 圆形轨道 */
    offset-distance: calc(var(--i) / var(--items) * 100%); /* 按索引定位到对应分区 */
  }
}

核心:repeating-conic-gradient 实现转盘彩色分区,offset-path 让奖项文本沿圆形轨道均匀分布。

5. 中心按钮交互

.controls {
  position: absolute;
  z-index: 2; /* 层级高于转盘,确保可点击 */
  border-radius: 9in; /* 圆形按钮 */
  &::before { /* 转盘顶部指针 */
    content: '';
    border: 2vw solid var(--marker-bg-color);
    border-bottom-width: 4vw;
    border-top/left/right-color: transparent; /* 三角指针形状 */
  }
  label:hover { scale: 1.2; rotate: 20deg; } /* 鼠标悬浮时图标放大旋转 */
}

核心:伪元素实现转盘「指针」,hover 动效提升交互反馈,两个 SVG 图标通过 scale 控制显隐。

6. 全局基础样式

@layer base {
  :root {
    color-scheme: light dark; /* 适配系统明暗色模式 */
    --clr-bg: light-dark(var(--bg-light), var(--bg-dark)); /* 自动切换背景色 */
  }
  body {
    min-height: 100svh; /* 适配移动端安全区 */
    display: grid;
    place-content: center; /* 垂直水平居中 */
  }
}

核心:light-dark() 自动适配系统明暗模式,100svh 避免移动端地址栏遮挡,网格布局实现内容居中。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

BEM、OOCSS、SMACSS、ITCSS、AMCSS、SUITCSS:CSS命名规范简介

作者 漂流瓶jz
2026年2月26日 23:37

本来是希望讲一下CSS组件化发展历史上的技术,但所有内容放到一个文章中描述太长了,因此对各类技术分开写一下。这篇文章讲一下CSS命名规范。

在前端开发中,不同组件/模块的class类名都是公用的,假设两个组件中起了同样的类名,那么就会出现样式污染。既然问题出在名字,那么让不同组件的类名不同就能解决问题了。因此,社区中出现了一些CSS命名规范,希望使用规范将CSS的冲突污染减少,同时通过命名起到和HTML标签关系更紧密,封装公共CSS样式,以及一些其它作用。

BEM

BEM介绍

BEM是最知名的CSS命名规范,由Yandex团队开发。BEM的全称为Block Element Modifier,翻译成和中文就是块,元素和修饰符。BEM使用这三种层级来规范CSS的命名:

  • Block 区块 表示页面中一个独立可复用的模块或者组件
  • Element 元素 表示区块中的一个组成元素
  • Modifier 修饰符 修饰元素的状态或者行为

每个层级内部使用串行命名法(kebab-case),中间分隔单词使用单连字符-。元素前的分隔符为双下划线__,修饰符前的分隔符为双连字符--。元素不能独立存在,必须依附于区块内。修饰符则必须跟在元素或者区块后面。因此可以这样组合命名:

  • block 单区块
  • block__element 区块+元素
  • block--modifier 区块+修饰符
  • block__element--modifier 区块+元素
<div class="container">
  <input class="container__input" />
  <button class="container__button--primary">提交<button>
</div>

<style>
.container {}
.container__input {}
.container__button--primary {}
</style>

在上面的例子中,container是区块,input和button是元素,primary则是修饰符。这样每个元素都有自己的类型,不需要考虑名称冲突的问题,而且这样命名是有页面结构含义在的,即通过命名就知道这个元素属于哪个组件,有什么用处。因此,BEM也不推荐使用嵌套选择器。

BEM的应用和优缺点

BEM的应用比较广泛,很多项目都是使用它来命名class,还有一些项目利用了他的命名思路。这里我们以Vue3的组件库Element-Plus为例,来看一下BEM的应用:

css-name-1.png

这里是一个复合型输入框组件,名称叫做el-input-group。组件包含左边的前置展示元素和右边的输入框,其中组件结构和以BEM方式命名的class如下:

  • el-input-group--prepend: 区块 el-input-group 修饰符 prepend
    • el-input-group__prepend: 区块 el-input-group 元素 prepend
    • el-input__wrapper: 区块 el-input 元素 wrapper
      • el-input__inner: 区块 el-input 元素 inner

通过这种方式,Element-Plus有着清晰的元素class名,不仅组件内部开发使用,使用组件库的用户也可以使用这些类名来覆盖组件库样式。下面我们来总结一下BEM命名规范的优缺点:

  • 优点
    • 清晰的类名,只看class就能知道元素的作用和归属,不会发生混淆
    • 组件和组件之间的名称是独立的,不会样式污染
    • 提供了命名规范,团队协作开发时命名不会混乱,也可以提供给外部使用
  • 缺点
    • 对于包含很多元素的复杂组件,仅仅三个层级,命名可能并不够用
    • 组件名称太长,对开发者并不方便

这些优缺点不仅仅是BEM的优缺点,也是大部分CSS命名规范的优缺点了。

OOCSS

面对对象简介

OOCSS的全称为Object Oriented CSS,即为面对对象的CSS。接触过编程的同学大多知道,Object Oriented即面对对象,是一种编程模式,是将一些数据属性和对应的方法结合起来,抽象成一个类,类可以生成实例对象。面对对象还有继承,封装,多态等特性。这里举个简单的例子:

类别 类名 属性 方法
基类 水果 名称 重量 体积 切开水果
子类 继承水果 苹果 甜度 做苹果派
子类 继承水果 橘子 酸度 作陈皮

每一种类都封装了属性和方法。苹果和橘子都是水果的子类,继承了水果的属性和方法。子类可以有自己独立的方法,也能调用父类的方法。调用父类的方法时,可以有子类自己的实现,这是多态。例如苹果和橘子都可以使用切开水果这个方法,但切开的效果不一样。一个类可以生成很多个实例对象,每个对象可以有不同的数据。

JavaScript中也有面对对象相关的方法,老方法有原型链,ES6中直接提供了class关键字,并且在逐渐完善面对对象相关的语法。但CSS并不是编程语言,无法提供直接提供面对对象语法,只能在概念上简单模拟一下。OOCSS就是利用CSS,对面对对象的概念进行了简单的模拟。

分离结构和皮肤

按照OOCSS的设想,CSS样式可以分为结构structure和皮肤skin。结构表示它的尺寸/位置/边距等内容;皮肤表示颜色,字体,背景等。因为皮肤可能会根据不同的场景变化,而且皮肤可能被多个组件所公用,因此分开作为两个类来处理。这里我们举个例子,首先是不使用OOCSS的做法,两个CSS类独立互相没有依赖:

<div>
  <button class="btn-small">jzplp按钮1</button>
  <button class="btn-large">jzplp按钮2</button>
<div>
<style>
.btn-small {
  width: 20px;
  height: 20px;
  Padding: 5px;
  color: red;
  background: blue;
}
.btn-large {
  width: 200px;
  height: 200px;
  Padding: 50px;
  color: red;
  background: blue;
}
</style>

这样写会造成一些重复属性存在,例如这里的skin相关属性就是重复的,我们将他抽象出来作为单独的skin共享:

<div>
  <button class="btn-small btn-skin">jzplp按钮1</button>
  <button class="btn-large btn-skin">jzplp按钮2</button>
<div>
<style>
.btn-skin {
  color: red;
  background: blue;
}
.btn-small {
  width: 20px;
  height: 20px;
  Padding: 5px;
}
.btn-large {
  width: 200px;
  height: 200px;
  Padding: 50px;
}
</style>

这样皮肤的样式就可以在不同的元素中复用了。如果要修改皮肤,修改一个位置就统一修改了所有元素的皮肤。

分离容器和内容

很多人在写CSS时,遇到容器和内容这样组合的HTML结构,经常会把CSS也写为组合的样式,例如与HTML一样也保持了父子的结构。但OOCSS认为,这样限制了这些CSS的引用场景,不利于其它元素复用这些CSS代码。需要将它们分开撰写。这里举个例子,首先依然是嵌套CSS的场景:

<div class="container">
  <div>jzplp内容1</div>
  <div>jzplp内容2</div>
<div>
<style>
.container {
  width: 100%;
  height: 200px;
  div {
      width: 30px;
      margin-right: 10px;
      height: 100%;
  }
}
</style>

假设有其它场景只希望复用内部div的CSS代码,是没有办法的,因为嵌套的结构限制了这里的使用场景。因此按照OOCSS的设想,应该不使用嵌套结构,将CSS代码解耦:

<div class="container">
  <div class="content">jzplp内容1</div>
  <div class="content">jzplp内容2</div>
<div>
<style>
.container {
  width: 100%;
  height: 200px;
}
.content {
  width: 30px;
  margin-right: 10px;
  height: 100%;
}
</style>

OOCSS的优缺点

除了上面OOCSS的两个原则“分离结构和皮肤/分离容器和内容”之外,OOCSS最核心的原则其实是:拆开元素的CSS样式,变为更方便复用,更独立的样式。上面两个原则是这个核心原则的部分具体做法。

这时候有些同学会问,这些原则和面对对象有什么关系?实话说我也觉得关系确实不大。但按照OOCSS的说法,我们定义的类选择器就是面对对象中的类。将这个类的提供给HTML元素,就相当于将这个类实例化。使用OOCSS的原则,拆开的可复用CSS样式相当于基类,那些拆开后依然无法复用的CSS样式称为子类。(例如前面btn-small是子类,btn-skin是父类)。

如果这样抽象的话,即使不了解OOCSS的开发者,肯定也无意间使用过OOCSS的原则,也用过“面对对象方法”组织过CSS。这里我们总结一下OOCSS的优缺点:

  • 优点
    • 复用已有的CSS规则更方便(这也是OOCSS的核心原则)
    • CSS文件更少,可提高页面加载速度(这也是复用程度高造成的)
    • 有利于CSS规则更新和扩展(只改一个CSS规则,所有位置都可以生效)
  • 缺点
    • 一个元素上可能挂多个类名,可能造成属性混乱
    • 如何拆分抽象公共CSS规则需要根据业务设计与平衡
    • 结构和皮肤有时候时互相关联的,有时候并不容易区分
    • 部分CSS本身就要求父子有联系,例如flex,grid布局等等,必须要求父子元素独立可能并不适合

总之,OOCSS只是一个组织CSS的思路,我们不需要教条化的拆分,而是根据具体场景拆分和抽象公共CSS规则。

SMACSS

SMACSS的全称叫做Scalable and Modular Architecture for CSS,意思是可扩展和模块化的CSS结构。他与OOCSS类似,也是制定了一些CSS组织的规范,但比OOCSS更细致。这两个命名规范的思想上有很多相似之处。SMACSS将页面的CSS规则分为五种类型,下面我们将分别介绍:

  • Base 基础样式
  • Layout 布局样式
  • Module 模块样式
  • State 状态样式
  • Theme 主题样式

Base基础样式

基础样式是整个页面通用的公共样式。一个常用的例子是CSS reset样式表。在CSS优先级,没有想的那么简单!全面介绍影响CSS优先级的各类因素中我们介绍过,浏览器会提供一些预置的默认样式,叫做“用户代理样式表”。但是很多用户不希望使用这些默认样式,因此使用一个全局的CSS reset样式表处理这些默认样式。

除了reset样式表之外,基础样式还可以包含一些对于所有元素通用的样式,例如标题样式,默认链接样式,页面背景等。SMACSS不推荐在基础样式中使用类或者ID选择器。例如:

body, form {
  margin: 0;
  padding: 0;
}
a {
  color: #039;
}
a:hover {
  color: #03F;    
}
body {
  background-color: red;
}

Layout布局样式

布局指的是将页面划分为几个大部分,这几个部分的样式作为布局样式。例如页面可以划分为头部、主内容区、底部、侧边栏等。这些样式通常是全局样式,一个布局元素中可以包含很多个模块。如果布局元素确定只出现一次,甚至可以使用ID选择器。可以使用l-或者layout-前缀来表示是布局样式,但也可以不使用。这里举几个例子:

#header, #article, #footer {
    width: 960px;
    margin: auto;
}
.sidebar {
    float: right;
}

Module模块样式

SMACSS中的模块和其它CSS命名规范中模块的含义一致,都是页面中独立可复用的模块,也就是组件。模块中的规则避免使用ID选择器或者元素选择器,而使用类名。为了规则不发生冲突,每个模块内部可以用模块名称本身作为前缀,例如.module-。

.card { padding: 5px; }
.card-top { font-size: 10px; }

State状态样式

SMACSS中的状态类似于BEM中的修饰符modifier,它表示模块或者布局在某些状态下的外观或者行为。但SMACSS中的状态样式倾向于是全局使用的,即多个模块和布局都可以使用。状态样式也可以是依赖JavaScript驱动的,例如点击或者其它操作展示的效果。状态样式可以用is-作为前缀。因为要覆盖元素本身的默认样式,因此允许使用!important。

.is-collapsed {
  width: 10px;
}
.is-selected {
  color: red !important;
}
/* 仅供模块使用的状态规则,可以添加模块前缀 */
.is-card-selected {
  color: yellow !important;
}

Theme主题样式

主题描述了模块或布局的外观样式,一些小的页面不要求主题样式,但有些页面有特殊要求,甚至要求换肤。将皮肤抽象出来作为的独立样式,方便抽象和更改。这里和OOCSS的皮肤规则有点像。

.normal {
  color: blue;
  background: grey;
}

.primary {
  color: red;
  background: white;
}

SMACSS的优缺点

SMACSS不仅描述了五种CSS规则类型,还包含很多规范说明,比如:类名规范、选择器使用规范和性能优化、字体、页面状态变化、嵌套选择器、与HTML5集成,与CSS预处理器集成、特殊CSS规则、甚至是CSS代码缩进等等。这里我们总结一下SMACSS的优缺点:

  • 优点
    • 提供了比较详尽的CSS组织规范
    • 考虑到了各种类型的公共样式,组件/模块的独立样式,可复用和隔离能力相对平衡
    • 由于比较详尽,更有利于团队协作开发
  • 缺点
    • 规范比较落后,没有适应现在前端框架的发展,有些想法也过时了
    • Layout也经常以模块/组件的形式组织
    • 规范太详尽,导致经常出现不符合实际情况的场景
    • 虽然说了不要死板套用,但如果不符合的场景太多,那还是需要重新定义自己的规范

ITCSS

ITCSS的全称为Inverted Triangle Cascading Style Sheets,翻译成中文为倒三角CSS。ITCSS把CSS规则分成了七层,并且把这七层展示为了一个倒三角的形式。

css-name-2.png

倒三角的形式指的是从上到下CSS规则的普遍性减少,特殊性增加,即越往下,影响范围和可复用性越低。这里我们说明一下每一层的内容:

  • Settings 预先定义的颜色变量,数值变量等
  • Tools 全局使用的mixins和函数等
  • Generic 全局标准化样式,例如CSS reset样式表
  • Elements HTML元素的通用样式
  • Objects 整个工程的布局样式,但不包含外观属性
  • Components 具体的组件样式
  • Trumps 可以覆盖的辅助样式,可以接受!important

可以看到,前两层都没有真正的CSS规则代码;三四层是不带类选择器的CSS规则。ITCSS利用了CSS预处理的特性,例如mixins和函数等。

AMCSS

AMCSS的全称为Attribute Modules for CSS,即使用属性作为模块的CSS。它与其它CSS命名规范都不相同:其它命名规范主要使用HTML的class属性作为选择器,而它则采用自定义HTML属性作为选择器。

  • Modules 模块
    • 类似于BEM中区块和元素的概念
    • 使用HTML属性描述,属性名称采用大驼峰命名法BlockName,如果嵌套子模块名使用连字符-
  • Variations 变体
    • 类似于BEM中的修饰符,表示模块中变化的部分,用来新增和覆盖部分属性
    • 使用HTML属性值描述,多个用空格分隔
  • Traits 特征
    • 一组某个用途的CSS规则,可以用来描述一些公共的CSS
    • 同一组特征的HTMl属性相同,值不同。特征的属性名采用小驼峰式命名法featureName

上面讲的有点晦涩,这里还是要用实际例子说明一下。AMCSS要求属性名添加前缀,推荐am-,其它前缀也可以。

<div am-MainCard>
</div>
<div am-Card>
  <div am-Card-Container> jzplp1 </div>
</div>
<div am-Card="sp1 primary"> 
  <div am-textType="title"> jzplp2 </div>
</div>

<style>
  /* 仅模块名 */
  [am-Card] { color: red; }
  /* 模块名采用大驼峰命名法 */
  [am-MainCard] { color: red; }
  /* 子模块名使用连字符- */
  [am-Card-Container] { color: red; }
  /* 变体使用属性 */
  [am-Card~="primary"] { color: red; }
  /* 特征名使用小驼峰式命名法 */
  [am-textType] { color: red; }
  /* 特征名和限制特征值 */
  [am-textType~="title"] { color: red; }
</style>

可以看到,AMCSS实际上就是将类选择器的那一套用法搬到了属性选择器上面,属性选择器的~=符号同样支持多个属性值。而且由于属性有属性名和属性值两种,因此相比于class名更灵活也更清晰。这种属性命名方式并不是推荐的HTML规范,但也可以正常使用。

SUITCSS

SUITCSS是一套组件化的样式工具。它不仅包含CSS命名规范,而且也提供了一些CSS预设包,构建工具,预处理器(实际上是PostCSS的插件集合),测试工具等。这里我们主要描述一下命名规范:

  • 公共样式: 表示一些公共样式
    • 命名规则 u-[sm-|md-|lg-]<utilityName>
    • 使用-u开头,后面跟骆驼命名法。中间也可以加响应式规则sm-|md-|lg-
  • 组件样式:描述独立组件内部的样式
    • 命名规则 [<namespace>-]<ComponentName>[-descendentName][--modifierName]
    • namespace 可选的命名空间,例如组件库中的组件避免与业务组件冲突,可以加前缀,例如 el-label, el-tag等。
    • ComponentName 组件名称,用Pascal命名法。组件名称需要与其他组件不同。
    • descendentName 组件内后代的名称,即为组件内部组成元素的类名,使用骆驼命名法。
    • modifierName 组件修饰符,修饰元素的状态或者行为。使用骆驼命名法,且前面有两个连字符。

SUITCSS命名规范中还规定了组件的设计原则,CSS变量名的命名方式,预置公共样式,甚至是代码风格等。

总结

即使没有了解过这些命名方案,其中的部分思想在我们的开发中也不知不觉会用到一些。这些命名规范确实能够解决很多问题,在前端发展的历史中起到过很多作用,也引导和启发了后续CSS组件化和工程化的发展。

但这些命名规范需要“手工处理”:手工定义各种名称,手工抽象CSS文件等。一个人开发还好,如果是多人协作团队开发,还要让每个人遵守规则,检查代码,这就成了一个麻烦的问题(少量规范有工具)。另外规范给出的类名大多很长,虽然更容易识别代码含义,但也造成了代码冗长,代码传输速度慢。

另外很多命名规范都有这样一个冲突:如果规范将CSS代码分类和组织的太过明确,这会造成应用范围小,很多工程根本不适用。如果规范将CSS代码分类和组织的太模糊,那代码就太随心所欲了,与没定义差不多。因此我们最好根据每个工程的具体实际情况定义合适的规范和抽象。

还有很多CSS命名规范比较老,跟不上时代发展。有些老旧的规范并不适应部分新内容:例如新的CSS布局方案,CSS变量,前端框架,CSS Modules,CSS代码格式规范(有自动化工具)等。CSS命名规范也存在互相吸收想法和思路的,晚出的方案相对更完善一些,但没有早出的方案更知名。

参考

HTML&CSS:高颜值产品卡片页面,支持主题切换

作者 前端Hardy
2026年2月26日 16:01

这是一个产品卡片页面,无 JS 实现图片切换主题切换、全设备响应式适配,兼顾美观与实用性,值得大家学习。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

演示效果

HTML&CSS

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>现代极简卧室套装</title>
    <style>
        body {
            font-family: "Roboto Serif", serif;
            display: flex;
            justify-content: center;
            align-items: center;
            margin: 0;
            min-height: 100vh;
            background-color: #f6f1ea;
            background-image: radial-gradient(circle at 15% 20%, rgba(232, 186, 142, 0.35) 0%, rgba(232, 186, 142, 0.18) 20%, rgba(232, 186, 142, 0.08) 35%, transparent 60%), radial-gradient(circle at 85% 75%, rgba(220, 160, 110, 0.35) 0%, rgba(220, 160, 110, 0.15) 25%, transparent 55%), radial-gradient(circle at 60% 10%, rgba(255, 210, 170, 0.25) 0%, transparent 50%);
            background-repeat: no-repeat;
            background-attachment: fixed;
        }

        body::after {
            content: "";
            position: fixed;
            inset: 0;
            pointer-events: none;
            z-index: 10;
            mix-blend-mode: saturation;
            background: radial-gradient(circle at 20% 25%, rgba(255, 200, 150, 0.35), rgba(255, 200, 150, 0.15) 30%, transparent 60%), radial-gradient(circle at 80% 70%, rgba(255, 170, 110, 0.3), transparent 60%);
            filter: blur(80px);
        }

        .product-card {
            border-radius: 12rem;
            corner-shape: squircle;
            padding: 1rem 1.5rem 1rem 1rem;
            max-width: 800px;
            background: linear-gradient(145deg, rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0.55));
            backdrop-filter: blur(20px);
            border: 1px solid rgba(255, 255, 255, 0.4);
            box-shadow: 0 40px 80px rgba(206, 168, 132, 0.1), 0 20px 40px rgba(0, 0, 0, 0.1), 0 0 120px rgba(198, 169, 126, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.6), -5px -5px 10px 0 #fffce9;
        }

        @media (max-width: 768px) {
            .product-card {
                border-radius: 5rem;
            }
        }

        .product-card .card-content {
            display: flex;
            justify-content: center;
            gap: 32px;
        }

        @media (max-width: 768px) {
            .product-card .card-content {
                flex-direction: column;
            }
        }

        .product-card .image-container {
            position: relative;
            border-radius: 12rem;
            width: 45vw;
            max-width: 450px;
            corner-shape: squircle;
            aspect-ratio: 1/1;
            overflow: hidden;
        }

        @media (max-width: 768px) {
            .product-card .image-container {
                width: 100%;
                border-radius: 5rem;
            }
        }

        .product-card .info-container {
            display: flex;
            flex-direction: column;
            justify-content: center;
            color: #5f5a55;
        }

        .product-card .info-container .brand {
            display: block;
            padding-bottom: 3rem;
            font-size: 0.85rem;
        }

        .product-card .info-container h1 {
            line-height: 100%;
            font-size: 2rem;
            font-weight: 600;
            letter-spacing: -0.1rem;
            margin: 0;
            padding: 0;
            transform: scaleY(2);
        }

        .product-card .info-container .price {
            font-size: 1.3rem;
            font-weight: 400;
            margin: 0;
            padding: 2.5rem 0 0;
            color: #c89b5e;
        }

        .product-card .info-container .description {
            max-width: 280px;
            font-size: 0.85rem;
            font-weight: 200;
            line-height: 150%;
            margin: 0;
            padding: 1rem 0;
        }

        .product-card .info-container .btn-primary {
            margin-top: 1rem;
            padding: 1rem 2rem;
            max-width: 200px;
            font-size: 1rem;
            letter-spacing: 1px;
            color: #ffffff;
            background: linear-gradient(145deg, #d8a45c, #b97a2f);
            border: none;
            border-radius: 40px;
            cursor: pointer;
            box-shadow: 0 8px 20px rgba(201, 155, 94, 0.35), 0 0 25px rgba(201, 155, 94, 0.15), inset 0 2px 6px rgba(255, 255, 255, 0.3);
            transition: 0.3s ease;
        }

        @media (max-width: 768px) {
            .product-card .info-container .btn-primary {
                max-width: none;
            }
        }

        .product-card .info-container .btn-primary:hover {
            transform: translateY(-2px);
            filter: brightness(1.05);
            box-shadow: 0 10px 25px rgba(185, 122, 47, 0.45), inset 0 2px 6px rgba(255, 255, 255, 0.3);
        }

        .product-card .info-container .btn-primary:active {
            transform: translateY(1px);
            box-shadow: 0 5px 15px rgba(185, 122, 47, 0.3), inset 0 3px 6px rgba(0, 0, 0, 0.15);
        }

        .image {
            aspect-ratio: 1/1;
            width: 100%;
            background: url("https://assets.codepen.io/662051/Gemini_Generated_Image_j4wi73j4wi73j4wi.png") center;
            background-size: cover;
            transition: 0.4s ease;
        }

        .theme-switch__input:checked~.image {
            background-image: url("https://assets.codepen.io/662051/Gemini_Generated_Image_2q32sc2q32sc2q32.png");
        }

        .theme-switch {
            position: absolute;
            left: 50%;
            bottom: 20px;
            transform: translateX(-50%);
            cursor: pointer;
        }

        .theme-switch__input {
            display: none;
        }

        .theme-switch__container {
            position: relative;
            width: 60px;
            height: 32px;
            padding: 5px;
            background-color: #e2e2e2;
            border-radius: 32px;
            display: flex;
            align-items: center;
            box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.1);
            transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
        }

        .theme-switch__circle {
            width: 30px;
            height: 30px;
            background-color: #333;
            border-radius: 50%;
            z-index: 2;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
            transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
        }

        .theme-switch__icons {
            position: absolute;
            width: 100%;
            box-sizing: border-box;
            z-index: 2;
        }

        .theme-switch .icon {
            width: 18px;
            height: 18px;
            transition: opacity 0.3s ease;
        }

        .theme-switch .icon--sun {
            opacity: 1;
            color: #fff;
            transform: translate(6px, 2px);
        }

        .theme-switch .icon--moon {
            opacity: 0;
            color: #333;
            transform: translate(15px, 2px);
        }

        .theme-switch__input:checked+.image+.theme-switch .theme-switch__container {
            background-color: #222;
        }

        .theme-switch__input:checked+.image+.theme-switch .theme-switch__circle {
            transform: translateX(30px);
            background-color: #fff;
        }

        .theme-switch__input:checked+.image+.theme-switch .icon--sun {
            opacity: 0;
        }

        .theme-switch__input:checked+.image+.theme-switch .icon--moon {
            opacity: 1;
            color: #333;
        }

        #dev {
            font-family: "Montserrat", sans-serif;
            position: fixed;
            top: 10px;
            left: 10px;
            padding: 1em;
            font-size: 14px;
            color: #333;
            background-color: white;
            border-radius: 25px;
            cursor: pointer;
        }

        #dev a {
            text-decoration: none;
            font-weight: bold;
            color: #333;
            transition: all 0.4s ease;
        }

        #dev a:hover {
            color: #ef5350;
            text-decoration: underline;
        }

        #dev span {
            display: inline-block;
            color: pink;
            transition: all 0.4s ease;
        }

        #dev span:hover {
            transform: scale(1.2);
        }
    </style>
</head>

<body>
    <div class="product-card">
        <div class="card-content">
            <div class="image-container">
                <input type="checkbox" id="theme-toggle" class="theme-switch__input">
                <div class="image"></div>
                <label for="theme-toggle" class="theme-switch">
                    <div class="theme-switch__container">
                        <div class="theme-switch__circle"></div>
                        <div class="theme-switch__icons">
                            <svg class="icon icon--sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
                                fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
                                stroke-linejoin="round">
                                <circle cx="12" cy="12" r="5"></circle>
                                <line x1="12" y1="1" x2="12" y2="3"></line>
                                <line x1="12" y1="21" x2="12" y2="23"></line>
                                <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
                                <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
                                <line x1="1" y1="12" x2="3" y2="12"></line>
                                <line x1="21" y1="12" x2="23" y2="12"></line>
                                <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
                                <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
                            </svg>
                            <svg class="icon icon--moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
                                fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
                                stroke-linejoin="round">
                                <path d="M21 12.79A9 9 0 1 1 11.21 3
                       7 7 0 0 0 21 12.79z"></path>
                            </svg>
                        </div>
                    </div>
                </label>
            </div>
            <div class="info-container">
                <header>
                    <span class="brand">HOLIME</span>
                    <h1 class="title">现代极简<br />卧室套装</h1>
                    <p class="price">$50.00 <span></span></p>
                    <p class="description">
                        打造宁静休憩空间,这套现代极简卧室套装融合永恒设计与舒适体验,为您的家注入优雅与安宁。
                    </p>
                </header>
                <button class="btn-primary">加入购物车</button>
            </div>
        </div>
    </div>
</body>

</html>

HTML

  • prouct-card:产品卡片容器。核心视觉容器,用毛玻璃效果、圆角、渐变背景打造高级感
  • card-content:卡片内容区。弹性布局,分「图片区 + 信息区」,移动端自动改为垂直布局
  • image-container:产品图片容器。固定宽高比(1:1);② 包含切换图片的复选框、图片、切换按钮;圆角适配移动端
  • info-container:产品信息区。垂直布局,包含品牌、标题、价格、描述、加入购物车按钮
  • theme-toggle:图片切换复选框。隐藏的核心交互控件,通过「选中 / 未选中」切换产品图片
  • theme-switch:切换按钮(夜间/白天)。视觉化的切换控件,绑定到隐藏的复选框,实现交互反馈

CSS

全局样式 & 背景

body {
  font-family: "Roboto Serif", serif;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  /* 背景层:暖色调径向渐变,营造温馨的卧室氛围 */
  background-color: #f6f1ea;
  background-image: radial-gradient(...), radial-gradient(...), radial-gradient(...);
  background-repeat: no-repeat;
  background-attachment: fixed;
}
body::after {
  /* 叠加模糊渐变层,增强层次感,mix-blend-mode 提升饱和度 */
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none; /* 不遮挡交互 */
  mix-blend-mode: saturation;
  background: radial-gradient(...);
  filter: blur(80px);
}

核心:flex 实现页面居中 + 多层径向渐变背景 + 伪元素叠加模糊层,打造「柔和、有呼吸感」的视觉基底。

产品卡片核心样式

.product-card {
  border-radius: 12rem; /* 超大圆角,接近胶囊/圆角矩形 */
  corner-shape: squircle; /* 松鼠角(非标准但现代浏览器支持,更圆润的圆角) */
  padding: 1rem 1.5rem 1rem 1rem;
  max-width: 800px;
  /* 毛玻璃核心:半透明背景 + backdrop-filter */
  background: linear-gradient(145deg, rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0.55));
  backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.4);
}

核心:backdrop-filter: blur(20px) 实现毛玻璃效果,linear-gradient 半透明白色渐变增强通透感,超大圆角提升现代感。

响应式布局

@media (max-width: 768px) {
  .product-card { border-radius: 5rem; }
  .product-card .card-content { flex-direction: column; }
  .product-card .image-container { width: 100%; border-radius: 5rem; }
  .product-card .info-container .btn-primary { max-width: none; }
}

核心:屏幕宽度 ≤768px(移动端)时,① 缩小卡片 / 图片圆角;② 图片 + 信息从「横向排列」改为「垂直排列」;③ 按钮宽度自适应,适配移动端交互。

图片切换交互

/* 默认图片 */
.image {
  aspect-ratio: 1/1;
  width: 100%;
  background: url("xxx.png") center;
  background-size: cover;
  transition: 0.4s ease;
}
/* 复选框选中时切换图片 */
.theme-switch__input:checked~.image {
  background-image: url("yyy.png");
}

核心:利用 CSS 相邻兄弟选择器 ~,监听复选框 :checked 状态,切换背景图片,配合 transition 实现平滑过渡。

开关按钮 & 按钮动效

/* 开关按钮样式切换 */
.theme-switch__input:checked+.image+.theme-switch .theme-switch__container {
  background-color: #222;
}
.theme-switch__input:checked+.image+.theme-switch .theme-switch__circle {
  transform: translateX(30px);
  background-color: #fff;
}
/* 加入购物车按钮 hover/active 动效 */
.btn-primary:hover {
  transform: translateY(-2px);
  filter: brightness(1.05);
  box-shadow: ...;
}
.btn-primary:active {
  transform: translateY(1px);
  box-shadow: ...;
}

核心:① 开关按钮的「白天 / 夜间」图标显隐、背景色、滑块位置随复选框状态变化;② 按钮 hover 时上移 + 亮度提升,active 时下移 + 阴影缩小,模拟「按压反馈」。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

❌
❌