普通视图

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

一份合格的软件 VI 文字文档简单版

作者 LeonGao
2026年3月26日 09:57

0、先破后立:别把软件 VI 当“改个 Logo、换套颜色”,那只是皮;真正的 VI 是让产品长期“长得一致、说得一致、做得一致”。

很多团队写 VI,写着写着就变成“视觉资产打包”:给几个色值、放几张示例图,然后结束。结果是页面越做越多,风格越漂越远;新同事接手时靠猜,外包交付时靠运气。合格的软件 VI 文字文档,目标很直白:降低沟通成本,减少返工,让每一次新增页面和每一次改版都更可控
下面这份“简单版”,按工程口径写:交付、可控、复现、成本、安全,再加一个“品牌一致性”(因为 VI 不讲这个就失焦)。每段只讲能落地的内容。


1、交付:一份合格的 VI 文档,第一要件是“能直接拿去做”,而不是“看完感觉对”。

文档里要把交付件列死,别含糊。最少包含这些条目:

  1. 品牌一句话:产品是什么、给谁用、想让人记住什么气质(3 行以内)。
  2. 命名规则:产品名、模块名、功能名怎么写;中英文、大小写、空格、连字符统一口径。
  3. 基础视觉资产:Logo 规范(留白、最小尺寸、禁用示例)、主辅色、字体与字号层级、图标风格一句话定义。
  4. 核心组件口径:按钮、输入框、表单、弹窗、表格、通知、空状态、加载态、错误态——每个给“何时用 + 不要怎么用”。
  5. 文案基调:提示语、报错、确认弹窗的语气;能不能用感叹号,能不能用俚语,默认怎么称呼用户。
  6. 输出形式:PDF/在线文档链接/设计稿入口/组件库入口/更新日志入口,一次交齐。

写 VI 不怕短,怕“交付不完整”。只要能让别人照着做,就合格了一半。


2、可控:VI 不是审美表达,是约束系统;约束越清楚,风格越不跑偏。

可控的关键是“边界”。你要明确哪些能改,哪些不能动:

  • 固定项:Logo 使用、主色、字体体系、间距网格、圆角、阴影、图标线宽等,写成“禁止项 + 例外条件”。
  • 可变项:活动页允许更活泼?营销位能用渐变?插画能不能换风格?把弹性范围写清。
  • 优先级规则:冲突时听谁的——可用性优先还是品牌优先?无障碍优先还是视觉优先?给一句硬规则,别让团队现场吵架。
  • 审查口径:什么程度需要设计 review,什么程度产品经理就能放行;把门槛写出来。

一句话:可控的 VI,是把“我觉得”变成“按规则”。


3、复现:合格的 VI 文档要能“复刻同一种效果”,让新人和外包也能做出同款。

复现靠的是“步骤”和“示例”,而不是长篇概念。建议写三类最有用的东西:

  • 页面模板:后台列表页、详情页、表单页、移动端信息流页,各给一个骨架:栅格、边距、标题区、操作区、信息区怎么排。
  • 状态全家桶:成功/失败/警告/处理中/禁用/空/无权限/网络错误——每种状态的颜色、图标、文案、按钮策略写清。
  • Do / Don’t:每个关键点给 1–2 个正例和反例,反例越具体越省时间。

复现的判断标准很简单:把文档扔给一个没参与项目的人,他能不能做出和你们差不多的页面。


4、成本:VI 文档的价值不在“写得多”,在“减少重复劳动、减少返工”。

成本控制靠三件事:

  1. 组件优先:先规定组件,再谈页面。页面是组件的组合,组件稳定,页面就不会乱。
  2. 最小可用集:别一口气把所有场景写全,先把 80% 高频场景写到能用:按钮、表单、表格、弹窗、提示、空状态。其余用“扩展条款”挂着。
  3. 版本与变更机制:写清楚谁维护、多久更新一次、如何提改动、如何废弃旧规范。没有变更机制的 VI,半年就变成古董。

一句话:VI 写得越像“团队公共资产”,后期越省钱。


5、安全:软件 VI 也有安全线,尤其是“信息展示”和“误操作”两类雷。

很多人以为安全只在后端,其实界面一样能埋坑。VI 文档至少要写这些底线:

  • 敏感信息展示规则:手机号、身份证、邮箱、金额、定位、密钥类字段,默认如何脱敏;什么场景允许全量展示;截图风险怎么提示。
  • 权限与不可见:无权限是“隐藏入口”还是“展示但置灰”,不同产品不同策略,但必须统一。
  • 高危操作样式:删除、清空、解绑、转账、发布、回滚等,危险按钮颜色、二次确认文案、默认焦点放哪、是否需要输入校验(如输入“DELETE”)写清。
  • 错误信息口径:前台提示要对用户友好,后台日志要对排查友好;哪些信息不能在前台暴露(例如内部路径、表名、堆栈片段)。

一句话:VI 不是只管好看,也要管“不出事”。


6、品牌一致性:软件 VI 的终点是“用户认得出这是你”,不靠花哨,靠一致。

品牌一致性落到文字文档里,最有效的是把“气质”翻译成可执行规则:

  • 语气词典:哪些词常用、哪些词禁用;同义词选哪一个(例如“保存/提交/确认”统一用哪个)。
  • 信息层级:主标题怎么写,副标题怎么写,按钮文案用动词还是名词;长度上限建议。
  • 插画与动效:能不能用拟物、能不能用夸张表情、动效节奏偏快还是偏稳;动效使用场景边界写清。
  • 跨端一致:Web、iOS、Android、桌面端是否同一套组件口径;差异允许在哪些点出现(如导航样式、系统字体)。

一句话:一致性不是“都一样”,而是“变化有规则”。


快速测评清单(拿这张表,你就能自己验收 VI 文档合不合格)

  1. 交付完整度:是否明确列出交付件清单,并能一键找到入口(文档、设计稿、组件库、更新日志)。
  2. 可控性:是否写清固定项/可变项/例外条件/审查门槛,团队争议点能否按规则裁决。
  3. 复现能力:给新人 2 小时,他能否按文档做出列表页+表单页,并且样式接近现有产品。
  4. 返工概率:同一页面改三次后,是否还在改“风格不对”而不是改“需求变化”。
  5. 组件覆盖率:按钮/表单/表格/弹窗/通知/空状态/错误态是否齐全,缺口是否有扩展条款承接。
  6. 安全底线:脱敏、高危操作、权限态、错误提示是否有统一口径,并给出示例文案。
  7. 跨端一致:多端差异是否被允许且被记录,还是靠各端“自己理解”。
  8. 可维护性:是否有版本号、维护人、变更流程、废弃策略;能否追溯每次改动原因。
  9. 可读性:是否短句为主、规则清晰、示例够用;读者扫一遍能抓住要点。
  10. 落地结果:随机抽 5 个页面,视觉与文案是否能看出同一套体系,组件是否复用而非手搓。
昨天以前首页

Fixed 定位的失效问题

作者 氢灵子
2026年3月25日 14:56

通常情况下position: fixed元素相对于视口定位,但是某些情况下,比如祖先元素设置了transformfilterperspectivewill-change: transform的时候,子元素的固定定位会失效,不在相对于视口定位,而是相对于该祖先元素定位,约等于绝对定位。

比如:

<div style="position: fixed; top: 50vh; right: 0; width: 10vh; height: 10vh; background-color: lightblue"></div>

<div style="transform: translate(0, 0); padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightcoral"></div>
</div>
<div style="filter: blur(0); padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightcyan"></div>
</div>
<div style="perspective: 0; padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightgoldenrodyellow"></div>
</div>
<div style="will-change: transform; padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightgray"></div>
</div>

第一个元素定位正常,但后面的元素定位异常,这是因为这些元素的父元素因为特定的 CSS 属性被放在新的图层之中。

一般情况下,当我们发现了固定定位异常时,排查祖先元素是否含有上述的 CSS 属性即可。但有一种情况,虽然在浏览器的 CSS 面板中看不到上述属性,但元素依然处于不同的图层中。这就是当元素被执行过animate且执行了上述 CSS 属性的动画。

比如:

<div id="moving">
  <div style="position: fixed; top: 0; width: 10vh; height: 10vh; background-color: lightblue"></div>
</div>

<script>
  let moving = window.document.getElementById('moving');
  moving.animate([{ transform: 'translate(0, 0)' }, { transform: 'translate(0, 50vh)' }], { duration: 1000, fill: 'forwards' });
</script>

如果运行上面的代码,可以看到固定定位的元素在跟随父元素移动,同时此时看到浏览器的 CSS 面板中父元素并没有 transform 相关属性。

不得不说,好坑啊。

有趣味的登录页它踏着七彩祥云来了

作者 BugShare
2026年3月23日 23:09

最近,有一个比较火的很有趣且灵动的登录页火了。

  • 角色视觉跟随鼠标
  • 输入框打字时扯脖子瞅
  • 显示密码明文时避开视线

PixPin_2026-03-23_14-13-18.gif

已经有大神(katavii)复刻了动画效果,并在github上开源了:github.com/katavii/ani… ,基于React实现。

如果你的项目是用Vue开发的,可以考虑用AI将此项目转换成了Vue3的语法写法。

最简单的方式,直接用Claude Code一句话就能完成,根据模型能力,你可能需要多次调试。

claude
帮我把这个项目转成vue3 + ant-design-vue的前端项目

以下是我的转换代码,如果你的AI代码没有调试成功,可以参考下。

创建项目

现在开发前端项目,肯定首选Vite

pnpm create vite
# 选择Vue模板、TypeScript语法

PixPin_2026-03-23_14-19-09.png

封装组件

src/components/创建animated-characters文件夹

EyeBall

创建 src/components/animated-characters/EyeBall.vue,制作动画的大眼睛

<template>
  <div
    class="eyeball"
    :data-max-distance="maxDistance"
    :style="eyeballStyle"
  >
    <div
      class="eyeball-pupil"
      :style="pupilStyle"
    />
  </div>
</template>

<script setup lang="ts">
interface Props {
  size?: string
  pupilSize?: string
  maxDistance?: number
  eyeColor?: string
  pupilColor?: string
}

const {
  size,
  pupilSize,
  maxDistance,
  eyeColor,
  pupilColor
} = withDefaults(defineProps<Props>(), {
  size: '48px',
  pupilSize: '16px',
  maxDistance: 10,
  eyeColor: 'white',
  pupilColor: 'black'
})

const eyeballStyle = {
  width: size,
  height: size,
  borderRadius: '50%',
  backgroundColor: eyeColor,
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  overflow: 'hidden',
  willChange: 'height'
}

const pupilStyle = {
  width: pupilSize,
  height: pupilSize,
  borderRadius: '50%',
  backgroundColor: pupilColor,
  willChange: 'transform'
}
</script>

PixPin_2026-03-23_14-45-23.png

Pupil

创建 src/components/animated-characters/Pupil.vue,制作动画的小眼睛

<template>
  <div
    :data-max-distance="maxDistance"
    class="pupil"
    :style="pupilStyle"
  />
</template>

<script setup lang="ts">
interface Props {
  size?: string
  maxDistance?: number
  pupilColor?: string
}

const {
  size,
  maxDistance,
  pupilColor
} = withDefaults(defineProps<Props>(), {
  size: '12px',
  maxDistance: 5,
  pupilColor: 'black'
})

const pupilStyle = {
  width: size,
  height: size,
  borderRadius: '50%',
  backgroundColor: pupilColor,
  willChange: 'transform'
}
</script>

PixPin_2026-03-23_14-46-10.png

角色

安装依赖

pnpm install gsap --save

创建 src/components/animated-characters/Index.vue,制作动画的角色

props属性

- is-typing         是否正在输入
- show-password     显示密码明文
- password-length   密码输入框是否有值
<template>
  <div ref="containerRef" :style="containerStyle">
    <!-- 紫色角色 -->
    <div
      ref="purpleRef"
      :style="purpleBodyStyle"
    >
      <div ref="purpleFaceRef" :style="purpleFaceStyle">
        <EyeBall
          size="18px"
          pupil-size="7px"
          :max-distance="5"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
        <EyeBall
          size="18px"
          pupil-size="7px"
          :max-distance="5"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
      </div>
    </div>

    <!-- 黑色角色 -->
    <div
      ref="blackRef"
      :style="blackBodyStyle"
    >
      <div ref="blackFaceRef" :style="blackFaceStyle">
        <EyeBall
          size="16px"
          pupil-size="6px"
          :max-distance="4"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
        <EyeBall
          size="16px"
          pupil-size="6px"
          :max-distance="4"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
      </div>
    </div>

    <!-- 橘黄色角色 -->
    <div
      ref="orangeRef"
      :style="orangeBodyStyle"
    >
      <div ref="orangeFaceRef" :style="orangeFaceStyle">
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
      </div>
    </div>

    <!-- 黄色角色 -->
    <div
      ref="yellowRef"
      :style="yellowBodyStyle"
    >
      <div ref="yellowFaceRef" :style="yellowFaceStyle">
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
      </div>
      <div ref="yellowMouthRef" :style="yellowMouthStyle" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount, watch, toRef } from 'vue'
import gsap from 'gsap'
import Pupil from './Pupil.vue'
import EyeBall from './EyeBall.vue'

interface Props {
  isTyping?: boolean
  showPassword?: boolean
  passwordLength?: number
}

const props = withDefaults(defineProps<Props>(), {
  isTyping: false,
  showPassword: false,
  passwordLength: 0
})

const containerRef = ref<HTMLElement | null>(null)
const mouseRef = reactive({ x: 0, y: 0 })
const rafIdRef = ref<number>(0)

const purpleRef = ref<HTMLElement | null>(null)
const blackRef = ref<HTMLElement | null>(null)
const yellowRef = ref<HTMLElement | null>(null)
const orangeRef = ref<HTMLElement | null>(null)

const purpleFaceRef = ref<HTMLElement | null>(null)
const blackFaceRef = ref<HTMLElement | null>(null)
const yellowFaceRef = ref<HTMLElement | null>(null)
const orangeFaceRef = ref<HTMLElement | null>(null)

const yellowMouthRef = ref<HTMLElement | null>(null)

const purpleBlinkTimerRef = ref<ReturnType<typeof setTimeout>>()
const blackBlinkTimerRef = ref<ReturnType<typeof setTimeout>>()
const purplePeekTimerRef = ref<ReturnType<typeof setTimeout>>()

const isHidingPassword = toRef(() => props.passwordLength > 0 && !props.showPassword)
const isShowingPassword = toRef(() => props.passwordLength > 0 && props.showPassword)

const isLookingRef = ref(false)
const lookingTimerRef = ref<ReturnType<typeof setTimeout>>()

const stateRef = reactive({
  isTyping: false,
  isHidingPassword: false,
  isShowingPassword: false,
  isLooking: false
})

watch(
  () => [props.isTyping, isHidingPassword.value, isShowingPassword.value, isLookingRef.value] as const,
  ([isTyping, isHiding, isShowing, isLooking]) => {
    stateRef.isTyping = isTyping
    stateRef.isHidingPassword = isHiding
    stateRef.isShowingPassword = isShowing
    stateRef.isLooking = isLooking
  }
)

// GSAP quickTo instances
const quickToRef = ref<Record<string, any> | null>(null)

const containerStyle = {
  position: 'relative' as const,
  width: '550px',
  height: '400px'
}

const purpleBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: '70px',
  width: '180px',
  height: '400px',
  backgroundColor: '#6C3FF5',
  borderRadius: '10px 10px 0 0',
  zIndex: 1,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const blackBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: '240px',
  width: '120px',
  height: '310px',
  backgroundColor: '#2D2D2D',
  borderRadius: '8px 8px 0 0',
  zIndex: 2,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const orangeBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: 0,
  width: '240px',
  height: '200px',
  backgroundColor: '#FF9B6B',
  borderRadius: '120px 120px 0 0',
  zIndex: 3,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const yellowBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: '310px',
  width: '140px',
  height: '230px',
  backgroundColor: '#E8D754',
  borderRadius: '70px 70px 0 0',
  zIndex: 4,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const purpleFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '32px',
  left: '45px',
  top: '40px'
})

const blackFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '24px',
  left: '26px',
  top: '32px'
})

const orangeFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '32px',
  left: '82px',
  top: '90px'
})

const yellowFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '24px',
  left: '52px',
  top: '40px'
})

const yellowMouthStyle = ref<any>({
  position: 'absolute',
  width: '80px',
  height: '4px',
  backgroundColor: '#2D2D2D',
  borderRadius: '9999px',
  left: '40px',
  top: '88px'
})

// Initialize GSAP
onMounted(() => {
  gsap.set('.pupil', { x: 0, y: 0 })
  gsap.set('.eyeball-pupil', { x: 0, y: 0 })
})

onMounted(() => {
  if (
    !purpleRef.value ||
    !blackRef.value ||
    !orangeRef.value ||
    !yellowRef.value ||
    !purpleFaceRef.value ||
    !blackFaceRef.value ||
    !orangeFaceRef.value ||
    !yellowFaceRef.value ||
    !yellowMouthRef.value
  )
    return

  const qt = {
    purpleSkew: gsap.quickTo(purpleRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    blackSkew: gsap.quickTo(blackRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    orangeSkew: gsap.quickTo(orangeRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    yellowSkew: gsap.quickTo(yellowRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    purpleX: gsap.quickTo(purpleRef.value, 'x', { duration: 0.3, ease: 'power2.out' }),
    blackX: gsap.quickTo(blackRef.value, 'x', { duration: 0.3, ease: 'power2.out' }),
    purpleHeight: gsap.quickTo(purpleRef.value, 'height', { duration: 0.3, ease: 'power2.out' }),
    purpleFaceLeft: gsap.quickTo(purpleFaceRef.value, 'left', { duration: 0.3, ease: 'power2.out' }),
    purpleFaceTop: gsap.quickTo(purpleFaceRef.value, 'top', { duration: 0.3, ease: 'power2.out' }),
    blackFaceLeft: gsap.quickTo(blackFaceRef.value, 'left', { duration: 0.3, ease: 'power2.out' }),
    blackFaceTop: gsap.quickTo(blackFaceRef.value, 'top', { duration: 0.3, ease: 'power2.out' }),
    orangeFaceX: gsap.quickTo(orangeFaceRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
    orangeFaceY: gsap.quickTo(orangeFaceRef.value, 'y', { duration: 0.2, ease: 'power2.out' }),
    yellowFaceX: gsap.quickTo(yellowFaceRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
    yellowFaceY: gsap.quickTo(yellowFaceRef.value, 'y', { duration: 0.2, ease: 'power2.out' }),
    mouthX: gsap.quickTo(yellowMouthRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
    mouthY: gsap.quickTo(yellowMouthRef.value, 'y', { duration: 0.2, ease: 'power2.out' })
  }
  quickToRef.value = qt

  const calcPos = (el: HTMLElement) => {
    const rect = el.getBoundingClientRect()
    const cx = rect.left + rect.width / 2
    const cy = rect.top + rect.height / 3
    const dx = mouseRef.x - cx
    const dy = mouseRef.y - cy
    return {
      faceX: Math.max(-15, Math.min(15, dx / 20)),
      faceY: Math.max(-10, Math.min(10, dy / 30)),
      bodySkew: Math.max(-6, Math.min(6, -dx / 120))
    }
  }

  const calcEyePos = (el: HTMLElement, maxDist: number) => {
    const r = el.getBoundingClientRect()
    const cx = r.left + r.width / 2
    const cy = r.top + r.height / 2
    const dx = mouseRef.x - cx
    const dy = mouseRef.y - cy
    const dist = Math.min(Math.sqrt(dx ** 2 + dy ** 2), maxDist)
    const angle = Math.atan2(dy, dx)
    return { x: Math.cos(angle) * dist, y: Math.sin(angle) * dist }
  }

  const tick = () => {
    const container = containerRef.value
    if (!container) return

    const { isTyping: typing, isHidingPassword: hiding, isShowingPassword: showing, isLooking: looking } = stateRef

    if (purpleRef.value && !showing) {
      const pp = calcPos(purpleRef.value)
      if (typing || hiding) {
        qt.purpleSkew(pp.bodySkew - 12)
        qt.purpleX(40)
        qt.purpleHeight(440)
      } else {
        qt.purpleSkew(pp.bodySkew)
        qt.purpleX(0)
        qt.purpleHeight(400)
      }
    }

    if (blackRef.value && !showing) {
      const bp = calcPos(blackRef.value)
      if (looking) {
        qt.blackSkew(bp.bodySkew * 1.5 + 10)
        qt.blackX(20)
      } else if (typing || hiding) {
        qt.blackSkew(bp.bodySkew * 1.5)
        qt.blackX(0)
      } else {
        qt.blackSkew(bp.bodySkew)
        qt.blackX(0)
      }
    }

    if (orangeRef.value && !showing) {
      const op = calcPos(orangeRef.value)
      qt.orangeSkew(op.bodySkew)
    }

    if (yellowRef.value && !showing) {
      const yp = calcPos(yellowRef.value)
      qt.yellowSkew(yp.bodySkew)
    }

    if (purpleRef.value && !showing && !looking) {
      const pp = calcPos(purpleRef.value)
      const purpleFaceX = pp.faceX >= 0 ? Math.min(25, pp.faceX * 1.5) : pp.faceX
      qt.purpleFaceLeft(45 + purpleFaceX)
      qt.purpleFaceTop(40 + pp.faceY)
    }

    if (blackRef.value && !showing && !looking) {
      const bp = calcPos(blackRef.value)
      qt.blackFaceLeft(26 + bp.faceX)
      qt.blackFaceTop(32 + bp.faceY)
    }

    if (orangeRef.value && !showing) {
      const op = calcPos(orangeRef.value)
      qt.orangeFaceX(op.faceX)
      qt.orangeFaceY(op.faceY)
    }

    if (yellowRef.value && !showing) {
      const yp = calcPos(yellowRef.value)
      qt.yellowFaceX(yp.faceX)
      qt.yellowFaceY(yp.faceY)
      qt.mouthX(yp.faceX)
      qt.mouthY(yp.faceY)
    }

    if (!showing) {
      const allPupils = container.querySelectorAll('.pupil')
      allPupils.forEach((p) => {
        const el = p as HTMLElement
        const maxDist = Number(el.dataset.maxDistance) || 5
        const ePos = calcEyePos(el, maxDist)
        gsap.set(el, { x: ePos.x, y: ePos.y })
      })

      if (!looking) {
        const allEyeballs = container.querySelectorAll('.eyeball')
        allEyeballs.forEach((eb) => {
          const el = eb as HTMLElement
          const maxDist = Number(el.dataset.maxDistance) || 10
          const pupil = el.querySelector('.eyeball-pupil') as HTMLElement
          if (!pupil) return
          const ePos = calcEyePos(el, maxDist)
          gsap.set(pupil, { x: ePos.x, y: ePos.y })
        })
      }
    }

    rafIdRef.value = requestAnimationFrame(tick)
  }

  const onMove = (e: MouseEvent) => {
    mouseRef.x = e.clientX
    mouseRef.y = e.clientY
  }

  window.addEventListener('mousemove', onMove, { passive: true })
  rafIdRef.value = requestAnimationFrame(tick)

  onBeforeUnmount(() => {
    window.removeEventListener('mousemove', onMove)
    cancelAnimationFrame(rafIdRef.value)
  })
})

// Purple character blink
onMounted(() => {
  const purpleEyeballs = purpleRef.value?.querySelectorAll('.eyeball')
  if (!purpleEyeballs?.length) return

  const scheduleBlink = () => {
    purpleBlinkTimerRef.value = setTimeout(() => {
      purpleEyeballs.forEach((el) => {
        gsap.to(el, { height: 2, duration: 0.08, ease: 'power2.in' })
      })
      setTimeout(() => {
        purpleEyeballs.forEach((el) => {
          const size = Number((el as HTMLElement).style.width.replace('px', '')) || 18
          gsap.to(el, { height: size, duration: 0.08, ease: 'power2.out' })
        })
        scheduleBlink()
      }, 150)
    }, Math.random() * 4000 + 3000)
  }

  scheduleBlink()
  onBeforeUnmount(() => clearTimeout(purpleBlinkTimerRef.value))
})

// Black character blink
onMounted(() => {
  const blackEyeballs = blackRef.value?.querySelectorAll('.eyeball')
  if (!blackEyeballs?.length) return

  const scheduleBlink = () => {
    blackBlinkTimerRef.value = setTimeout(() => {
      blackEyeballs.forEach((el) => {
        gsap.to(el, { height: 2, duration: 0.08, ease: 'power2.in' })
      })
      setTimeout(() => {
        blackEyeballs.forEach((el) => {
          const size = Number((el as HTMLElement).style.width.replace('px', '')) || 16
          gsap.to(el, { height: size, duration: 0.08, ease: 'power2.out' })
        })
        scheduleBlink()
      }, 150)
    }, Math.random() * 4000 + 3000)
  }

  scheduleBlink()
  onBeforeUnmount(() => clearTimeout(blackBlinkTimerRef.value))
})

const applyLookAtEachOther = () => {
  const qt = quickToRef.value
  if (qt) {
    qt.purpleFaceLeft(55)
    qt.purpleFaceTop(65)
    qt.blackFaceLeft(32)
    qt.blackFaceTop(12)
  }
  purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: 3, y: 4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  blackRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: 0, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
}

const applyHidingPassword = () => {
  const qt = quickToRef.value
  if (qt) {
    qt.purpleFaceLeft(55)
    qt.purpleFaceTop(65)
  }
}

const applyShowPassword = () => {
  const qt = quickToRef.value
  if (qt) {
    qt.purpleSkew(0)
    qt.blackSkew(0)
    qt.orangeSkew(0)
    qt.yellowSkew(0)
    qt.purpleX(0)
    qt.blackX(0)
    qt.purpleHeight(400)

    qt.purpleFaceLeft(20)
    qt.purpleFaceTop(35)
    qt.blackFaceLeft(10)
    qt.blackFaceTop(28)
    qt.orangeFaceX(50 - 82)
    qt.orangeFaceY(85 - 90)
    qt.yellowFaceX(20 - 52)
    qt.yellowFaceY(35 - 40)
    qt.mouthX(10 - 40)
    qt.mouthY(0)
  }

  purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: -4, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  blackRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: -4, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  orangeRef.value?.querySelectorAll('.pupil').forEach((p) => {
    gsap.to(p, { x: -5, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  yellowRef.value?.querySelectorAll('.pupil').forEach((p) => {
    gsap.to(p, { x: -5, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
}

// Password peek effect
watch(
  () => [isShowingPassword.value, props.passwordLength],
  ([showing, len]) => {
    if (!showing || (len as number) <= 0) {
      clearTimeout(purplePeekTimerRef.value)
      return
    }

    const purpleEyePupils = purpleRef.value?.querySelectorAll('.eyeball-pupil')
    if (!purpleEyePupils?.length) return

    const schedulePeek = () => {
      purplePeekTimerRef.value = setTimeout(() => {
        purpleEyePupils.forEach((p) => {
          gsap.to(p, {
            x: 4,
            y: 5,
            duration: 0.3,
            ease: 'power2.out',
            overwrite: 'auto'
          })
        })
        const qt = quickToRef.value
        if (qt) {
          qt.purpleFaceLeft(20)
          qt.purpleFaceTop(35)
        }

        setTimeout(() => {
          purpleEyePupils.forEach((p) => {
            gsap.to(p, {
              x: -4,
              y: -4,
              duration: 0.3,
              ease: 'power2.out',
              overwrite: 'auto'
            })
          })
          schedulePeek()
        }, 800)
      }, Math.random() * 3000 + 2000)
    }

    schedulePeek()
    onBeforeUnmount(() => clearTimeout(purplePeekTimerRef.value))
  }
)

// Look at each other when typing
watch(
  () => [props.isTyping, isShowingPassword.value],
  ([typing, showing]) => {
    if (typing && !showing) {
      isLookingRef.value = true
      stateRef.isLooking = true
      applyLookAtEachOther()

      clearTimeout(lookingTimerRef.value)
      lookingTimerRef.value = setTimeout(() => {
        isLookingRef.value = false
        stateRef.isLooking = false
        purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
          gsap.killTweensOf(p)
        })
      }, 800)
    } else {
      clearTimeout(lookingTimerRef.value)
      isLookingRef.value = false
      stateRef.isLooking = false
    }
  }
)

// Password state effects
watch(
  () => [isShowingPassword.value, isHidingPassword.value],
  ([showing, hiding]) => {
    if (showing) {
      applyShowPassword()
    } else if (hiding) {
      applyHidingPassword()
    }
  }
)
</script>

PixPin_2026-03-23_15-09-29.gif

登录页

安装依赖

pnpm install --save ant-design-vue @ant-design/icons-vue

src/main.js添加以下内容

import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'

app.use(Antd)

创建 src/pages/login/Index.vue登录页

<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import {
  UserOutlined,
  LockOutlined,
  EyeOutlined,
  EyeInvisibleOutlined,
} from '@ant-design/icons-vue'
import AnimatedCharacters from '../../components/animated-characters/Index.vue'
import styles from './index.module.css'

/** 模拟登录 API(仅前端逻辑,无真实请求) */
async function mockLogin(_values: { username: string; password: string }) {
  await new Promise((resolve) => setTimeout(resolve, 800))
  return { data: { access_token: 'mock_token_' + Date.now() } }
}

const loading = ref(false)
const showPassword = ref(false)
const isTyping = ref(false)
const passwordValue = ref('')
const error = ref('')

const handleLogin = async (values: { username: string; password: string }) => {
  loading.value = true
  error.value = ''
  try {
    const { data } = await mockLogin(values)
    localStorage.setItem('access_token', data.access_token)
    message.success('登录成功')
    setTimeout(() => {
      window.location.href = '/'
    }, 500)
  } catch {
    error.value = '账号或密码有误,请重新输入'
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <div :class="styles.container">
    <!-- 左侧:品牌视觉区 -->
    <div :class="styles.leftPanel">
      <div :class="styles.leftTop">
        <div :class="styles.brandMark">
          <svg width="28" height="28" viewBox="0 0 28 28" fill="none">
            <rect width="28" height="28" rx="7" fill="white" fill-opacity="0.15" />
            <path d="M7 14L12 9L17 14L12 19L7 14Z" fill="white" fill-opacity="0.9" />
            <path d="M13 14L18 9L21 12V16L18 19L13 14Z" fill="white" fill-opacity="0.5" />
          </svg>
        </div>
        <span :class="styles.brandName">Nexus</span>
      </div>

      <div :class="styles.charactersArea">
        <AnimatedCharacters
            :is-typing="isTyping"
            :show-password="showPassword"
            :password-length="passwordValue.length"
        />
      </div>

      <div :class="styles.leftFooter">
        <a href="#">帮助中心</a>
        <a href="#">隐私政策</a>
      </div>

      <div :class="styles.decorBlur1" />
      <div :class="styles.decorBlur2" />
      <div :class="styles.decorGrid" />
    </div>

    <!-- 右侧:登录表单 -->
    <div :class="styles.rightPanel">
      <div :class="styles.formWrapper">
        <div :class="styles.mobileLogo">
          <div :class="styles.mobileLogoIcon">
            <svg width="20" height="20" viewBox="0 0 28 28" fill="none">
              <path d="M7 14L12 9L17 14L12 19L7 14Z" fill="#1E40AF" fill-opacity="0.9" />
              <path d="M13 14L18 9L21 12V16L18 19L13 14Z" fill="#3B82F6" fill-opacity="0.7" />
            </svg>
          </div>
          <span>Nexus 平台</span>
        </div>

        <div :class="styles.formHeader">
          <h1 :class="styles.formTitle">登录到工作台</h1>
          <p :class="styles.formSubtitle">
            统一接入前端平台旗下所有系统
          </p>
        </div>

        <a-form
            name="login"
            @finish="handleLogin"
            autocomplete="off"
            size="large"
            :class="styles.form"
        >
          <div :class="styles.fieldLabel">账号</div>
          <a-form-item
              name="username"
              :rules="[
              { required: true, message: '请输入账号' },
              { min: 3, message: '账号长度不能少于 3 个字符' },
            ]"
          >
            <a-input
                placeholder="输入您的账号"
                @focus="isTyping = true"
                @blur="isTyping = false"
            >
              <template #prefix>
                <UserOutlined :class="styles.prefixIcon" />
              </template>
            </a-input>
          </a-form-item>

          <div :class="styles.fieldLabel">密码</div>
          <a-form-item
              name="password"
              :rules="[
              { required: true, message: '请输入密码' },
              { min: 6, message: '密码长度不能少于 6 个字符' },
            ]"
          >
            <a-input
                :type="showPassword ? 'text' : 'password'"
                placeholder="输入您的密码"
                v-model:value="passwordValue"
            >
              <template #prefix>
                <LockOutlined :class="styles.prefixIcon" />
              </template>
              <template #suffix>
                <span
                    :class="styles.eyeToggle"
                    @click="showPassword = !showPassword"
                >
                  <EyeOutlined v-if="showPassword" />
                  <EyeInvisibleOutlined v-else />
                </span>
              </template>
            </a-input>
          </a-form-item>

          <div v-if="error" :class="styles.errorBox">{{ error }}</div>

          <a-form-item :style="{ marginBottom: 0 }">
            <a-button
                type="primary"
                html-type="submit"
                :loading="loading"
                block
                :class="styles.submitBtn"
            >
              {{ loading ? '登录中...' : '登录' }}
            </a-button>
          </a-form-item>
        </a-form>

        <div :class="styles.divider">
          <span>或</span>
        </div>

        <a-button block :class="styles.googleBtn">
          飞书账号一键登录
        </a-button>

        <div :class="styles.signupRow">
          暂无账号?
          <a href="#" :class="styles.signupLink">
            联系管理员申请开通
          </a>
        </div>
      </div>
    </div>
  </div>
</template>

创建 src/pages/login/index.module.css登录页样式

.container {
    min-height: 100vh;
    display: grid;
    grid-template-columns: 1fr 1fr;
}

@media (max-width: 1024px) {
    .container {
        grid-template-columns: 1fr;
    }
}

/* ─── 左侧面板 ───────────────────────────────────────────────────────────────── */

.leftPanel {
    position: relative;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    padding: 48px;
    background: linear-gradient(145deg, #0f172a 0%, #1e3a8a 50%, #1e40af 100%);
    overflow: hidden;
}

@media (max-width: 1024px) {
    .leftPanel {
        display: none;
    }
}

.leftTop {
    position: relative;
    z-index: 20;
    display: flex;
    align-items: center;
    gap: 10px;
    font-size: 20px;
    font-weight: 700;
    color: #ffffff;
    letter-spacing: 0.5px;
}

.brandMark {
    width: 40px;
    height: 40px;
    border-radius: 10px;
    background: rgba(255, 255, 255, 0.12);
    border: 1px solid rgba(255, 255, 255, 0.2);
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    backdrop-filter: blur(8px);
}

.brandName {
    color: #ffffff;
    font-size: 20px;
    font-weight: 700;
    letter-spacing: 1px;
}

.charactersArea {
    position: relative;
    z-index: 20;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    height: 500px;
}

.leftFooter {
    position: relative;
    z-index: 20;
    display: flex;
    align-items: center;
    gap: 24px;
}

.leftFooter a {
    font-size: 13px;
    color: rgba(255, 255, 255, 0.45);
    text-decoration: none;
    transition: color 0.2s;
    cursor: pointer;
}

.leftFooter a:hover {
    color: rgba(255, 255, 255, 0.85);
}

.decorBlur1 {
    position: absolute;
    top: 15%;
    right: 10%;
    width: 300px;
    height: 300px;
    background: rgba(59, 130, 246, 0.25);
    border-radius: 50%;
    filter: blur(80px);
    pointer-events: none;
    z-index: 0;
}

.decorBlur2 {
    position: absolute;
    bottom: 10%;
    left: 5%;
    width: 400px;
    height: 400px;
    background: rgba(30, 64, 175, 0.3);
    border-radius: 50%;
    filter: blur(100px);
    pointer-events: none;
    z-index: 0;
}

.decorGrid {
    position: absolute;
    inset: 0;
    background-image:
            linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
            linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
    background-size: 40px 40px;
    pointer-events: none;
    z-index: 1;
}

/* ─── 右侧面板 ───────────────────────────────────────────────────────────────── */

.rightPanel {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 32px;
    background: #ffffff;
}

.formWrapper {
    width: 100%;
    max-width: 400px;
}

.mobileLogo {
    display: none;
    align-items: center;
    justify-content: center;
    gap: 8px;
    font-size: 18px;
    font-weight: 700;
    color: #0f172a;
    margin-bottom: 48px;
}

@media (max-width: 1024px) {
    .mobileLogo {
        display: flex;
    }
}

.mobileLogoIcon {
    width: 32px;
    height: 32px;
    border-radius: 8px;
    background: #eff6ff;
    display: flex;
    align-items: center;
    justify-content: center;
}

.formHeader {
    text-align: center;
    margin-bottom: 40px;
}

.formTitle {
    font-size: 26px;
    font-weight: 700;
    letter-spacing: -0.02em;
    color: #0f172a;
    margin: 0 0 10px 0;
    line-height: 1.3;
}

.formSubtitle {
    font-size: 14px;
    color: #6b7280;
    margin: 0;
    line-height: 1.6;
}

.form :global(.ant-form-item) {
    margin-bottom: 20px;
}

.form :global(.ant-input-affix-wrapper) {
    height: 48px !important;
    background: #fafafa !important;
    border: 1px solid #e5e7eb !important;
    border-radius: 10px !important;
    transition: border-color 0.2s, box-shadow 0.2s !important;
}

.form :global(.ant-input-affix-wrapper:hover) {
    border-color: #3b82f6 !important;
}

.form :global(.ant-input-affix-wrapper:focus),
.form :global(.ant-input-affix-wrapper-focused) {
    border-color: #1e40af !important;
    box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.08) !important;
    background: #ffffff !important;
}

.form :global(.ant-input-affix-wrapper .ant-input) {
    background: transparent !important;
    font-size: 14px !important;
    color: #111827 !important;
}

.form :global(.ant-input-affix-wrapper .ant-input::placeholder) {
    color: #c0c4cc !important;
}

.form :global(.ant-form-item-explain-error) {
    font-size: 13px !important;
    margin-top: 4px !important;
}

.fieldLabel {
    font-size: 13px;
    font-weight: 500;
    color: #374151;
    margin-bottom: 6px;
    letter-spacing: 0.2px;
}

.prefixIcon {
    color: #b0b7c3;
    font-size: 15px;
}

.eyeToggle {
    color: #6b7280;
    cursor: pointer;
    font-size: 16px;
    display: flex;
    align-items: center;
    transition: color 0.2s;
}

.eyeToggle:hover {
    color: #374151;
}

.errorBox {
    padding: 10px 14px;
    font-size: 13px;
    color: #dc2626;
    background: #fef2f2;
    border: 1px solid #fecaca;
    border-radius: 8px;
    margin-bottom: 16px;
}

.submitBtn {
    height: 48px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    border-radius: 10px !important;
    background: #1e40af !important;
    border-color: #1e40af !important;
    letter-spacing: 1px;
    transition: background 0.2s, opacity 0.2s !important;
    cursor: pointer;
}

.submitBtn:hover {
    background: #1d4ed8 !important;
    border-color: #1d4ed8 !important;
    opacity: 1 !important;
}

.submitBtn:active {
    opacity: 0.85 !important;
}

.divider {
    display: flex;
    align-items: center;
    gap: 12px;
    margin: 20px 0 0;
    color: #d1d5db;
    font-size: 13px;
}

.divider::before,
.divider::after {
    content: '';
    flex: 1;
    height: 1px;
    background: #e5e7eb;
}

.divider span {
    color: #9ca3af;
    white-space: nowrap;
}

.googleBtn {
    height: 48px !important;
    font-size: 14px !important;
    border-radius: 10px !important;
    margin-top: 12px !important;
    background: #ffffff !important;
    border: 1px solid #e5e7eb !important;
    color: #374151 !important;
    transition: background 0.2s, border-color 0.2s !important;
    cursor: pointer;
}

.googleBtn:hover {
    background: #eff6ff !important;
    border-color: rgba(30, 64, 175, 0.25) !important;
    color: #1e40af !important;
}

.signupRow {
    text-align: center;
    font-size: 13px;
    color: #6b7280;
    margin-top: 28px;
}

.signupLink {
    color: #1e40af;
    font-weight: 500;
    text-decoration: none;
    cursor: pointer;
}

.signupLink:hover {
    text-decoration: underline;
    color: #1d4ed8;
}

源代码

vue2分支是Vue2 + Element-ui实现。

CSS 新特性完全指南:2026 年你必须掌握的 5 个新能力

2026年3月21日 15:23

CSS 新特性完全指南:2026 年你必须掌握的 5 个新能力

从容器查询到滚动驱动动画,掌握这些新特性让你的 CSS 代码更强大、更简洁


前言

如果你还在用媒体查询处理所有响应式布局,或者用 JavaScript 实现滚动动画,那么这篇文章可能会改变你写 CSS 的方式。

2026 年的 CSS 已经不再是当年那个只能做简单样式布局的语言了。容器查询、层叠层、滚动驱动动画、新颜色空间……这些新特性正在重新定义我们对 CSS 的认知。

更重要的是,这些特性在现代浏览器中的支持率已经超过 90%。现在不学,更待何时?


一、容器查询:比媒体查询更精准的响应式

1. 什么是容器查询

媒体查询监听的是视口大小,而容器查询监听的是元素容器的大小。这意味着你的组件可以在任何容器中自适应,真正实现了组件级的响应式。

/* 传统媒体查询 - 监听视口 */
@media (min-width: 768px) {
  .card {
    flex-direction: row;
  }
}

/* 容器查询 - 监听容器 */
@container (min-width: 400px) {
  .card {
    flex-direction: row;
  }
}

2. 实际应用场景

想象一个卡片组件,放在侧边栏时是垂直布局,放在主内容区时是水平布局。用容器查询,一套代码就能搞定。

/* 定义容器 */
.sidebar {
  container-type: inline-size;
}

.main-content {
  container-type: inline-size;
}

/* 卡片根据容器宽度自适应 */
@container (min-width: 300px) {
  .card {
    display: flex;
    flex-direction: row;
  }
  
  .card-image {
    width: 200px;
  }
}

@container (max-width: 299px) {
  .card {
    display: block;
  }
  
  .card-image {
    width: 100%;
  }
}

关键点:使用 container-type: inline-size 定义容器,然后用 @container 编写查询规则。

3. 命名容器

给容器起个名字,可以在嵌套组件中精准定位。

/* 命名容器 */
.main-sidebar {
  container-type: inline-size;
  container-name: sidebar;
}

/* 针对特定命名容器查询 */
@container sidebar (min-width: 250px) {
  .widget {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
  }
}

二、层叠层:彻底解决 CSS 优先级问题

1. 优先级困扰

你是否遇到过这种情况:明明选择器权重一样,但后面的样式就是覆盖不了前面的?或者为了覆盖第三方库的样式,不得不写上 !important

层叠层(Cascade Layers)就是来解决这个问题的。

2. 定义层叠层

/* 定义三个层 */
@layer reset, base, components;

/* reset 层优先级最低 */
@layer reset {
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }
}

/* base 层优先级中等 */
@layer base {
  body {
    font-family: system-ui;
    line-height: 1.5;
  }
}

/* components 层优先级最高 */
@layer components {
  .button {
    padding: 0.5rem 1rem;
    border-radius: 4px;
  }
}

3. 层内优先级规则

层与层之间的优先级由定义顺序决定,但层内的选择器依然遵循正常的优先级规则。

@layer components {
  /* 这个会被后面的覆盖 */
  .button {
    background: blue;
  }
  
  /* 这个生效 */
  .button {
    background: green;
  }
  
  /* 权重更高的选择器优先 */
  .card .button {
    background: red;
  }
}

推荐:将第三方库的样式放在低优先级层,自己的组件样式放在高优先级层,彻底告别 !important


三、滚动驱动动画:无需 JavaScript 的滚动效果

1. 滚动时间线

滚动驱动动画(Scroll-driven Animations)让你可以用纯 CSS 实现滚动触发的动画效果。

/* 进度条随页面滚动增长 */
.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  background: linear-gradient(to right, #3498db, #2ecc71);
  width: 0;
  
  animation: grow-progress auto linear;
  animation-timeline: scroll();
}

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

2. 元素进入视口动画

/* 元素进入视口时淡入上移 */
.fade-in-section {
  opacity: 0;
  transform: translateY(30px);
  
  animation: fade-in linear forwards;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}

@keyframes fade-in {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

animation-range 控制动画触发的时机:

  • entry 0%:元素顶部进入视口时开始
  • cover 40%:元素覆盖视口 40% 时结束

3. 横向滚动容器

/* 横向滚动时图片缩放 */
.scroll-container {
  display: flex;
  overflow-x: auto;
}

.scroll-container img {
  animation: scale-on-scroll linear;
  animation-timeline: scroll(x);
}

@keyframes scale-on-scroll {
  from {
    transform: scale(0.8);
  }
  to {
    transform: scale(1);
  }
}

四、新颜色空间:更丰富的色彩表达

1. oklch 颜色空间

oklch 是 2026 年最推荐的颜色表示方式,比 HSL 更符合人类视觉感知。

/* 传统 HSL */
.color-hsl {
  color: hsl(210, 100%, 50%);
}

/* 推荐的 oklch */
.color-oklch {
  color: oklch(60% 0.15 250);
}

/* oklch 参数说明 */
/* oklch(亮度 色度 色相) */
/* 亮度:0% - 100% */
/* 色度:0 - 0.4(人眼可感知范围) */
/* 色相:0 - 360 度 */

2. 颜色混合

/* 混合两种颜色 */
.mixed-color {
  background: oklch(from var(--primary) l c h / 0.8);
}

/* 生成颜色变体 */
.color-tint {
  background: oklch(90% 0.05 250); /* 浅色变体 */
}

.color-shade {
  background: oklch(30% 0.1 250); /* 深色变体 */
}

3. 相对颜色语法

基于现有颜色进行调整,无需手动计算。

:root {
  --primary: oklch(60% 0.15 250);
}

.button {
  /* 亮度增加 20% */
  background: oklch(from var(--primary) calc(l + 0.2) c h);
}

.button:hover {
  /* 色度增加 10% */
  background: oklch(from var(--primary) l calc(c * 1.1) h);
}

五、子网格:真正的嵌套网格布局

1. 子网格的作用

在 CSS Grid 中,嵌套的网格默认是独立的。子网格让子元素可以参与父元素的网格轨道。

/* 传统网格 - 子元素不参与父网格 */
.grid-parent {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
}

.grid-child {
  display: grid;
  /* 子元素的网格独立于父元素 */
  grid-template-columns: repeat(2, 1fr);
}

/* 子网格 - 子元素继承父网格轨道 */
.grid-child-subgrid {
  display: grid;
  grid-template-columns: subgrid;
  /* 子元素与父元素对齐 */
}

2. 卡片布局实战

/* 卡片容器 */
.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 2rem;
}

/* 卡片使用子网格 */
.card {
  display: grid;
  grid-template-columns: subgrid;
  grid-template-rows: auto 1fr auto;
  gap: 1rem;
}

.card-image {
  grid-column: 1 / -1; /* 跨整行 */
}

.card-content {
  /* 内容区域自动填充 */
}

.card-footer {
  grid-column: 1 / -1;
}

关键点:使用 subgrid 让卡片的内部网格与外部网格对齐,实现整齐的布局。

3. 表单布局

.form-grid {
  display: grid;
  grid-template-columns: 150px 1fr;
  gap: 1rem;
  align-items: center;
}

.form-row {
  display: grid;
  grid-template-columns: subgrid;
  /* 所有表单项的标签对齐 */
}

.form-row label {
  /* 标签列 */
}

.form-row input {
  /* 输入框列 */
}

六、实战案例:响应式产品卡片

综合运用以上特性,构建一个现代化的产品卡片组件。

案例背景

电商平台的产品卡片需要:

  • 在不同容器尺寸下自适应布局
  • 滚动时淡入动画
  • 清晰的层级结构
  • 易于维护的样式

实现步骤

  1. 使用容器查询实现响应式布局
  2. 使用层叠层管理样式优先级
  3. 使用滚动驱动动画添加进入效果
  4. 使用子网格确保内部对齐

完整代码

/* 定义层叠层 */
@layer reset, base, components, utilities;

/* 容器定义 */
.product-section {
  container-type: inline-size;
}

/* 产品网格 */
.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 2rem;
}

/* 产品卡片 */
.product-card {
  display: grid;
  grid-template-rows: auto 1fr auto;
  gap: 1rem;
  border-radius: 12px;
  overflow: hidden;
  background: white;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  
  /* 滚动动画 */
  opacity: 0;
  animation: card-fade-in linear forwards;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
}

@keyframes card-fade-in {
  to {
    opacity: 1;
  }
}

/* 容器查询 - 小容器 */
@container (max-width: 350px) {
  .product-card {
    grid-template-columns: 1fr;
  }
  
  .product-image {
    aspect-ratio: 1;
  }
}

/* 容器查询 - 大容器 */
@container (min-width: 351px) {
  .product-card {
    grid-template-columns: 200px 1fr;
    grid-template-rows: 1fr auto;
  }
  
  .product-image {
    grid-row: 1 / 2;
    aspect-ratio: auto;
  }
}

/* 卡片内部元素 */
.product-image {
  width: 100%;
  object-fit: cover;
}

.product-info {
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.product-title {
  font-size: 1.125rem;
  font-weight: 600;
  color: oklch(20% 0.02 250);
}

.product-price {
  font-size: 1.25rem;
  font-weight: 700;
  color: oklch(50% 0.2 140);
}

.product-actions {
  grid-column: 1 / -1;
  padding: 1rem;
  display: flex;
  gap: 0.75rem;
}

.add-to-cart {
  flex: 1;
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 8px;
  background: oklch(55% 0.15 250);
  color: white;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}

.add-to-cart:hover {
  background: oklch(from var(--btn-bg) calc(l - 0.1) c h);
}

七、最佳实践总结

  1. 容器查询 - 组件级响应式的首选方案,优先于媒体查询
  2. 层叠层 - 管理大型项目样式,避免优先级冲突
  3. 滚动动画 - 用纯 CSS 替代 JavaScript 滚动效果,性能更优
  4. oklch 颜色 - 更符合人眼感知的颜色空间,推荐使用
  5. 子网格 - 嵌套网格布局的终极解决方案
特性 浏览器支持 推荐指数 学习优先级
容器查询 92% ⭐⭐⭐⭐⭐
层叠层 89% ⭐⭐⭐⭐⭐
滚动动画 85% ⭐⭐⭐⭐
oklch 颜色 91% ⭐⭐⭐⭐⭐
子网格 87% ⭐⭐⭐⭐

总结

CSS 正在经历一场革命。这些新特性不是锦上添花,而是真正能提升开发效率和代码质量的工具。

容器查询让组件真正可复用,层叠层让样式管理更清晰,滚动动画让交互更流畅,oklch 让色彩更精准,子网格让布局更灵活。

现在就开始在你的项目中使用这些特性吧。从一个小组件开始,逐步引入,你会发现 CSS 原来可以这么强大。


参考资料

  1. MDN Web Docs - CSS 容器查询:developer.mozilla.org/zh-CN/docs/…
  2. MDN Web Docs - CSS 层叠层:developer.mozilla.org/zh-CN/docs/…
  3. MDN Web Docs - 滚动驱动动画:developer.mozilla.org/zh-CN/docs/…
  4. CSS Tricks - oklch 颜色空间指南:css-tricks.com/color-forma…
  5. Can I Use - CSS 特性支持查询:caniuse.com/

觉得文章对你有帮助?欢迎点赞收藏,分享给更多需要的朋友!

❌
❌