# 手把手复刻 DeepSeek 官网效果:玻璃拟态 + WebGL 流体 + Spring 动画,一篇讲透

先聊聊:为什么很多官网"看起来很高级"?

打开 deepseek.com,第一反应大概率是"哇,好看,这得是专业设计团队+一堆 fancy 库吧?"

但如果我们把它的首页拆开,会发现一个反直觉的事实:它没有用任何动效库,没有 3D 引擎,甚至没有几个动画。真正让它"高级"的,是三个叠加在一起的技术点:

  1. 玻璃拟态(Glassmorphism) ------ 半透明的磨砂卡片
  2. WebGL 流体背景 ------ 那片会流动的蓝色
  3. Spring 物理动画 ------ 顶栏随滚动"带弹性地收缩"

这篇文章,我们就手把手把这三点全部复刻出来。每一段代码我都会先讲"它到底是什么",再给你能跑的版本。


第一课:玻璃拟态 ------ 三行 CSS 让卡片"活了"

先感受一下

想象你把一块磨砂玻璃盖在一张蓝色海报上:

  • 玻璃本身不是全透明的 ------ 它能让你看到底下一点颜色,但看不真切
  • 玻璃表面有细小的颗粒 ------ 这是"模糊"
  • 玻璃的边缘在光线下有一圈淡淡的亮边 ------ 这是"半透明描边"

这就是玻璃拟态的全部。CSS 里对应三个属性:

css 复制代码
.glass-card {
  background: rgba(255, 255, 255, 0.3);  /* 半透明白,能看到底下 */
  backdrop-filter: blur(12px);            /* 把背后的内容模糊 12px */
  border: 1px solid rgba(255, 255, 255, 0.2); /* 半透明描边 = 光的边缘 */
  border-radius: 16px;
}

就这三行,你就有了一块"玻璃"。拿去套在任意背景上,它都会像真的玻璃一样,随着底下内容的变化而"折射"。

深入一步:为什么是 0.3 的透明度,不是 #fff?

这是新手最容易忽略的点。如果背景用纯白:

css 复制代码
/* ❌ 这样没有玻璃感,就是一个白卡片 */
.glass-card { background: #fff; }

纯白 = 不透光 = 底下那个蓝色 shader 全被挡住了。玻璃感的灵魂是"半透明",让背景的颜色透出来一点,但又保证前景文字还能看清。

DeepSeek 的做法是把这些值抽成一套"材质 token",全站统一(后面第三节讲)。先记结论:

token 值 用途
--ds-blur-glass 12px 全站统一的模糊强度(甜点值,别乱改)
--ds-surface-1 hsla(0,0%,100%,0.3) 一般玻璃卡片
--ds-surface-raised hsla(0,0%,100%,0.45) 需要"更实"一点的层(如滚动后的顶栏)

踩坑警告 1:Safari 认不出 backdrop-filter?

如果你在 Mac 的 Safari 里打开页面,发现玻璃效果没生效 ------ 那八成是这个坑:

backdrop-filter 是标准属性,但 Safari 至今需要厂商前缀 -webkit-backdrop-filter。

所以全站所有用 backdrop-filter 的地方,都要成对写:

css 复制代码
.glass-card {
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);  /* Safari 专属 */
}

写代码时不要用 JS 去设这个属性 (el.style.webkitBackdropFilter = ...), 因为 TypeScript 的类型定义里根本没有 webkitBackdropFilter 这个键,一写就报红。 把它写进 CSS 静态规则里最干净 ------ CSS 会自动帮你处理前缀和清理。


第二课:让背景"动起来"的其实是像素 ------ WebGL Shader 零基础入门

这是全文最难的一节,我保证把它拆到你闭着眼都能抄。我们先忘掉"着色器"这个吓人的词。

类比:GPU 是一群负责上色的小机器人

想象 GPU 里有几百万个小机器人 ,屏幕上有几百万个像素 ,一个机器人负责一个像素。你写一份"上色指令"(叫 shader ),然后复印几百万份发给每个机器人,它们同时开工、互不干扰,各自给自己负责的那个像素算出一个颜色。

因为它们是并行的,所以哪怕每帧算几百万次,也极快。这就是 GPU 能实时渲染的原因。

你只需要写那份"指令"(shader),然后画一个大三角形把屏幕盖满 ------ 剩下的交给小机器人们。

上色指令长什么样?

shader 用 GLSL 语言写。别被语法吓到,它就是个"每个像素执行一次的小函数"。下面这个是我们能跑起来的最小例子,实现一个从蓝到白的渐变:

glsl 复制代码
#version 300 es
precision mediump float;
uniform vec2 u_resolution;   // 屏幕尺寸(像素)
out vec4 fragColor;          // 最终输出到这个像素的颜色

void main() {
  // gl_FragCoord 是当前像素的坐标,除以屏幕尺寸就归一化成 0~1
  vec2 uv = gl_FragCoord.xy / u_resolution;
  // 按 x 坐标混合蓝色和白色
  vec3 col = mix(vec3(0.54, 0.64, 0.84), vec3(1.0), uv.x);
  fragColor = vec4(col, 1.0);
}

uv 是 0~1 的归一化坐标,mix(a, b, t) 是在 a 和 b 之间按比例 t 混合。 这一行就画出了渐变 ------ 注意,这不是浏览器帮你画的,是每个像素的小机器人自己算出来的。

核心难点:让渐变"不是渐变",而是云朵一样的色块

单色渐变太单调了。DeepSeek 背景那片"流体"其实是噪声(noise) 做出来的。我再用三个类比把它讲透:

  1. hash(哈希):一个"伪随机机器"。给它一个坐标,它永远吐出同一个"看似随机"的数(同一个输入永远同一个输出,所以画面是稳定的,不会闪)。
  2. noise(噪声) :把 hash 的结果平滑连接起来。相邻像素的颜色接近 ------ 你不会看到雪花点,而是看到"连绵起伏的小山丘"。
  3. fbm(分形噪声) :把好几层不同频率的噪声叠起来。低频层是大波浪,高频层是小波纹 ------ 叠加起来就像海面。这就是"流体感"的来源。

写成 GLSL 就是这样(这段可以直接抄进你的项目):

glsl 复制代码
// 伪随机:同一输入永远同一输出
float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}

// 平滑噪声:小山丘
float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);       // 平滑插值
  return mix(
    mix(hash(i),               hash(i + vec2(1, 0)), u.x),
    mix(hash(i + vec2(0, 1)),  hash(i + vec2(1, 1)), u.x),
    u.y
  );
}

// 分形噪声:叠几层,像海面
float fbm(vec2 p) {
  float v = 0.0;
  float a = 0.5;
  for (int i = 0; i < 5; i++) {
    v += a * noise(p);
    p *= 2.0;
    a *= 0.5;
  }
  return v;
}

然后主函数里把它当作"地形高度",映射成颜色:

glsl 复制代码
void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  float n = fbm(uv * 3.0);          // 采样噪声,得一个"高度"
  n = smoothstep(0.3, 0.7, n);      // 把渐变"硬切"成有层次的色带
  vec3 col = mix(vec3(0.54, 0.64, 0.84), vec3(1.0), n);
  fragColor = vec4(col, 1.0);
}

uv * 3.0 是把噪声"缩放",数字越大,云朵越小越密。你随手改改这个系数,就能看到完全不同的纹理 ------ 这就是调参的乐趣。

让"静态图"动起来

把 u_time(时间)加进噪声的坐标里,就能让画面流动:

glsl 复制代码
float n = fbm(uv * 3.0 + vec2(u_time * 0.05, 0.0));

坐标随时间偏移 → 每一帧的图案都在"移动" → 看起来就像流体在流动。

踩坑警告 2:为什么切个窗口回来,背景变白了?

你按上面的代码写完,可能遇到一个诡异现象:第一次渲染正常,一旦窗口 resize 或切到别的标签页再切回来,整个背景就白屏了。

原因:WebGL 默认在"交换缓冲区"(把画好的内容显示到屏幕上)之后会清空画布内容。如果你只在第一帧画了一次,那下次浏览器重绘时,它已经找不到之前画的东西了。

解决:创建上下文时加一个选项 preserveDrawingBuffer: true,让画布内容保留下来:

ts 复制代码
const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true });

代价是牺牲一点点性能(GPU 会少做几个优化),但我们的背景"只画一帧",完全无所谓。

把 shader 跑起来 ------ 完整可复制的 React 组件

把上面所有 GLSL 拼起来,就是一个能跑的静态流体背景组件(可以嵌进任何 React 项目,或改成原生 JS):

tsx 复制代码
"use client";
import { useEffect, useRef } from "react";

const FRAG = `#version 300 es
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
out vec4 fragColor;

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(
    mix(hash(i),               hash(i + vec2(1, 0)), u.x),
    mix(hash(i + vec2(0, 1)),  hash(i + vec2(1, 1)), u.x),
    u.y
  );
}
float fbm(vec2 p) {
  float v = 0.0, a = 0.5;
  for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; }
  return v;
}

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  float n = fbm(uv * 3.0 + vec2(u_time * 0.05, 0.0));
  n = smoothstep(0.3, 0.7, n);
  vec3 col = mix(vec3(0.541, 0.639, 0.839), vec3(1.0), n);
  col = mix(col, vec3(0.9, 0.93, 0.98), smoothstep(0.5, 0.9, n));
  fragColor = vec4(col, 1.0);
}`;

const VERT = `#version 300 es
in vec4 a_position;
void main() { gl_Position = a_position; }`;

export function FlowShader({ className }: { className?: string }) {
  const ref = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = ref.current!;
    const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true });
    if (!gl) return;

    function compile(type: number, src: string) {
      const s = gl.createShader(type)!;
      gl.shaderSource(s, src);
      gl.compileShader(s);
      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
        throw new Error(gl.getShaderInfoLog(s) ?? "shader compile error");
      }
      return s;
    }

    const prog = gl.createProgram()!;
    gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
    gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
    gl.linkProgram(prog);
    gl.useProgram(prog);

    // 一个铺满屏幕的三角形(两个三角形拼成正方形)
    const buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
      gl.STATIC_DRAW
    );
    const aPos = gl.getAttribLocation(prog, "a_position");
    gl.enableVertexAttribArray(aPos);
    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

    const uRes = gl.getUniformLocation(prog, "u_resolution");
    const uTime = gl.getUniformLocation(prog, "u_time");

    function draw(t: number) {
      const w = canvas.clientWidth * devicePixelRatio;
      const h = canvas.clientHeight * devicePixelRatio;
      canvas.width = w;
      canvas.height = h;
      gl.viewport(0, 0, w, h);
      gl.uniform2f(uRes, w, h);
      gl.uniform1f(uTime, t);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    }

    // 静态背景:只画一帧,resize 时重画
    draw(0);
    const ro = new ResizeObserver(() => draw(0));
    ro.observe(canvas);
    return () => ro.disconnect();
  }, []);

  return <canvas ref={ref} className={className} />;
}

解释几个关键点:

  • draw(0) 只画一次 ------ 因为 DeepSeek 的 hero 背景 99% 时间是"安静的静态图",不需要每帧重画。省 GPU。
  • ResizeObserver 在窗口尺寸变化时重新画一次,否则图像会被拉伸变模糊。
  • devicePixelRatio 保证在高 DPI 屏幕上不糊。

让 shader 只在"hover 时"流动(这是高级感的关键)

DeepSeek 的招聘卡背景有个非常妙的细节:平时它是静止的,鼠标一进去才开始流动,离开又安静下来。

实现上不用持续跑动画循环,而是维护一个 0~1 的强度系数 hoverT:

ts 复制代码
let hoverT = 0, hoverTarget = 0, uTime = 0, raf = 0;

function render() {
  // 每帧向目标逼近 8%,进入/退出都有"加速度"的过渡感
  hoverT += (hoverTarget - hoverT) * 0.08;

  if (hoverT > 0.001) {
    uTime += 0.016 * 4.0 * hoverT;   // 流动速度正比于强度
    drawFrame(uTime);
  } else if (uTime !== 0) {
    uTime = 0;                        // 退出后回到静态帧
    drawFrame(0);
  }

  // 关键:没在流动就不跑 RAF,零 GPU 消耗
  if (hoverT > 0.001 || hoverTarget > 0.001) {
    raf = requestAnimationFrame(render);
  } else {
    raf = 0;
  }
}

card.addEventListener("pointerenter", () => { hoverTarget = 1; if (!raf) raf = requestAnimationFrame(render); });
card.addEventListener("pointerleave", () => { hoverTarget = 0; if (!raf) raf = requestAnimationFrame(render); });

体会一下这个设计:

  • 进入时:hoverT 从 0 缓动到 1,流体"慢慢醒过来",速度逐渐加快
  • 离开时:hoverT 从 1 缓动到 0,流体"慢慢睡过去",最后停回静态帧
  • 没人 hover 时 :raf 归零,GPU 完全休息

这就是"背景是静的、只在需要时活过来" ------ 前面说的"高级感"很大程度上来自这里。


第三课:Spring 物理动画 ------ 顶栏随滚动"带弹性地收缩"

现在做最妙、也最容易做砸的部分。

先看效果

滚动超过 80px 时,顶栏从 1280px 宽收缩 到 980px,logo 区域的 padding 从 0 变成 16px。而且它收缩时不是匀速的,而是带一点"弹一下"的感觉。

关键细节:它的收缩不是"整体变小",而是 maxWidth 收窄 + padding 增大,左 padding 比右 padding 多 ------ 因为 logo 在左边,这样 logo 会被"挤向中心",配合宽度收缩形成一种"被压缩"的形变感。这是设计师埋的细节。

先讲个更简单的:指数缓动("追赶")

假设你想让一个小球从 A 点"平滑地"移动到 B 点,最无脑的写法是每帧走剩余距离的 18%:

js 复制代码
x += (target - x) * 0.18;

这就是指数缓动 。它永远到不了 B 点(永远差 18%),但每帧都在靠近。效果是"先快后慢",非常丝滑 ------ 光标环(后面讲)用的就是这个。

进阶:Spring(弹簧)

指数缓动有一个缺点:永远不会过冲,也不会有"弹性"。

DeepSeek 顶栏要的是"弹一下"的感觉,所以用的是弹簧模型。一个弹簧系统由三样东西决定:

参数 类比 作用
stiffness(刚度 k) 弹簧的硬度 k 越大,往回拉的力越大、到位越快
damping(阻尼 c) 空气阻力 c 越大,晃得越少
质量 m 物体的重量 我们设 m = 1

物理公式(看着吓人,其实就两行):

r 复制代码
加速度 = -k × (当前位置 - 目标位置) - c × 当前速度
速度 += 加速度 × 时间
位置 += 速度 × 时间

翻译成人话:

  1. 离目标越远,往回拉的力越大(弹簧在拽它)
  2. 速度越快,阻力越大(空气在拖它)
  3. 因为速度会"带着惯性"冲过头,所以会出现"过冲 → 拉回来 → 再过冲 → 再拉回",几次之后停下来

代码(可以直接抄):

js 复制代码
const stiffness = 180;
const damping = 28;

let x = 0;      // 当前值 0~1
let v = 0;      // 当前速度
let target = 0; // 目标值

function step(now) {
  const dt = Math.min(0.05, (now - lastT) / 1000); // 每帧实际流逝的时间
  lastT = now;

  const accel = -stiffness * (x - target) - damping * v;
  v += accel * dt;   // 先更新速度
  x += v * dt;       // 再用新速度更新位置

  // 把 0~1 的 x 映射成实际的 maxWidth / padding
  const p = Math.max(0, Math.min(1, x));
  bar.style.maxWidth = `${1280 + (980 - 1280) * p}px`;
  bar.style.paddingLeft = `${0 + (16 - 0) * p}px`;
  bar.style.paddingRight = `${0 + (6 - 0) * p}px`;

  if (Math.abs(target - x) > 0.0005 || Math.abs(v) > 0.0005) {
    raf = requestAnimationFrame(step);
  }
}

踩坑警告 3:v 和 x 的更新顺序不能反

上面代码里必须先更新 v,再用新 v 更新 x。这个叫"半隐式欧拉积分"。

如果你写反了(先 x 后 v),能量会在每一帧里"偷偷积累",弹簧会越震越厉害,最后失控。这是新手最容易踩的数学坑,记住"先 v 后 x"四个字就行。

踩坑警告 4(全文最隐蔽的坑):class 千万不要绑给 spring

现在到了"来来回回搞了好几次都没搞定"的名场面。

我第一次实现时,顶栏背景的 is-scrolled class 是这么切的:

js 复制代码
// ❌ 错误写法
if (p > 0.01) bar.classList.add("is-scrolled");
else          bar.classList.remove("is-scrolled");

看着很合理对吧?但滚回顶部时,背景消失总是比顶栏恢复"慢半拍" ------ 几何已经回位了,背景那层白雾还要再粘 300ms 才褪掉。

为什么?因为 spring 从 1 → 0 是指数衰减 ,最后 5%(从 0.05 到 0.01)要磨叽很久。而几何(maxWidth)在 p=0.001 时就已经"几乎等于"1280px 了,视觉上早就回位。于是:

  • 几何:p 一路缩到接近 0,视觉"瞬间"完成
  • class:还在等 p > 0.01 为 false,白等 300ms

修复方法:让 class 和 spring 彻底解耦。

  • 几何(maxWidth/padding)→ 交给 spring 去插值,享受"弹性"
  • class(is-scrolled)→ 直接由 scrollY 判断,不要经过 spring
js 复制代码
function onScroll() {
  const y = window.scrollY;
  target = y > 80 ? 1 : 0;              // spring 只管几何

  const scrolled = y > 80;
  bar.classList.toggle("is-scrolled", scrolled); // class 直接由滚动位置决定
}

而背景的淡入淡出,交给 CSS 的 transition 去处理:

css 复制代码
.headerBar {
  transition: background-color 0.18s ease-out, border-color 0.18s ease-out;
}
.headerBar.is-scrolled {
  background: hsla(0, 0%, 100%, 0.45);
  border-color: rgba(0, 0, 0, 0.1);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
}

这样做的结果(我们用 30ms 间隔实测过):

时间 scrollY 背景 maxWidth
t=0 400 rgba(255,255,255,0.45) 玻璃感 1007px
t=150ms 0 已经透明到 0.05 1124px
t=180ms 0 rgba(0,0,0,0) ✅ 背景已消失 1189px
t=330ms 0 透明 1279px(spring 弹回完)

看到没:背景在 180ms 就消失了(由 scrollY 直接驱动),而 maxWidth 还在继续弹回(由 spring 驱动)。因为 0.18s ≈ 形变时长,用户感觉它们是"一起完成"的 ------ 实际上两套计时完全独立。

这就是"看起来简单,做起来全是坑"的典型代表。


第四课:自造光标环 ------ 给鼠标加一条"尾巴"

先看效果

DeepSeek 上,鼠标移过链接/按钮时,会出现一个白色的圆圈跟着光标,而且不是紧贴光标,而是带一点"慢半拍"的跟随。停下来 1.6 秒不动,它还会自己淡出。

原理:根本不隐藏系统光标

注意,它没有隐藏系统光标,只是在光标上面叠了一个 div 跟着走。系统光标永远在最上面,这个环只是"装饰层"。

实现:RAF + 指数缓动(就是第一课那个"追赶")

js 复制代码
let mx = 0, my = 0;  // 鼠标真实位置
let rx = 0, ry = 0;  // 圆环当前位置(缓动后)

function render() {
  // 每帧向鼠标位置逼近 18%(跟光标环那个"尾巴"感)
  rx += (mx - rx) * 0.18;
  ry += (my - ry) * 0.18;
  if (Math.abs(mx - rx) < 0.05) rx = mx; // 足够接近就锁死,避免闪烁
  if (Math.abs(my - ry) < 0.05) ry = my;

  ring.style.transform = `translate3d(${rx}px, ${ry}px, 0)`;

  // 鼠标 1.6s 没动 → 淡出
  if (performance.now() - lastMove > 1600) {
    ring.classList.remove("is-hover");
  }

  if (Math.abs(mx - rx) > 0.05 || Math.abs(my - ry) > 0.05) {
    raf = requestAnimationFrame(render);
  } else {
    raf = 0; // 停下来 = 不耗性能
  }
}

CSS 部分:

css 复制代码
.cursorRing {
  position: fixed;
  top: 0; left: 0;
  pointer-events: none;   /* 关键:让鼠标事件"穿透"圆环,否则你点不到链接了 */
  z-index: 9999;
  width: 0; height: 0;
  border-radius: 50%;
  border: 1px solid rgb(229, 231, 235);
  background: transparent;
  mix-blend-mode: difference; /* 关键:见下 */
  transition: width 0.3s, height 0.3s, margin 0.3s, opacity 0.3s, background 0.3s;
}
.cursorRing.is-hover { opacity: 1; }
.cursorRing.is-blend {
  width: 64px; height: 64px;
  margin: -32px 0 0 -32px;   /* 居中 */
  border-color: transparent;
  background: #fff;
}

两个值得讲的设计细节

细节 1:mix-blend-mode: difference(差异混合)

这是让这个圆环"黑白通吃"的关键。它的原理是"反色":

  • 圆环是白色 #fff
  • 在深色背景上,difference 混合后 → 显示白色 ✅
  • 在白色背景上,difference 混合后 → 显示黑色 ✅

不用写两套主题,一套样式搞定所有背景。这就是"高级感"的底层逻辑之一 ------ 少即是多。

细节 2:触屏设备必须隐藏

css 复制代码
@media (hover: none), (pointer: coarse) {
  .cursorRing { display: none; }
}

不然手机用户会看到一个"漂在半空不动的白圈",极其诡异。


第五课:设计 Token ------ 高级感不是靠灵感,是靠"纪律"

到这你会发现,三节课用到的颜色、模糊值,翻来覆去就那么几个:

  • 半透明白:0.3 / 0.45
  • 模糊:12px
  • 描边:0.2

这就是 design system 的价值。把它们抽成变量(CSS 里叫 custom property / token),全站复用,好处有三:

  1. 改一处,全站生效 ------ 想调亮玻璃,改一个变量
  2. 强制一致 ------ 不会出现"这个卡片 12px 模糊、那个 20px"的失控
  3. 可读性强 ------ 代码里写 var(--ds-blur-glass),比写 blur(12px) 更清楚意图

一套可以直接抄的 token(DeepSeek 的同款结构):

css 复制代码
:root {
  /* 玻璃材质 */
  --ds-blur-glass: 12px;
  --ds-surface-1:      hsla(0, 0%, 100%, 0.3);
  --ds-surface-raised: hsla(0, 0%, 100%, 0.45);
  --ds-border-input:   hsla(0, 0%, 100%, 0.2);

  /* 文字 */
  --ds-text-primary:        #1e232c;
  --ds-text-primary-bluish: #152443;  /* slogan 专用,偏蓝的黑 */
  --ds-text-secondary:      rgba(0, 0, 0, 0.7);

  /* 品牌色 */
  --ds-brand:      #4d6bfe;
  --ds-brand-deep: #3a65c2;

  /* 圆角 */
  --ds-radius-pill: 100px;
  --ds-radius-card: 24px;
  --ds-radius-panel: 16px;
}

第六课:让"克制"落地 ------ 三条纪律

把上面所有技术做完,最后再讲一个比技术更重要的东西。看 DeepSeek 的 hero,你会发现它"什么都能做,但选择不做":

纪律 1:hover 不要加位移

很多网站在 hover 卡片时喜欢 transform: translateY(-4px)。DeepSeek 不做 ------ 卡片就是不动,让 shader 的流动和光标环去承担"被关注"的反馈。位移反而会破坏"稳定感"。

纪律 2:背景是主角,文字保持"静"

slogan 是 46px、字距 0.4em、深蓝色,从不放大、从不抖动。真正"活"的是背景 shader。这种"静 + 动"的反差,才让文字显得高级。

纪律 3:入场慢,响应快

  • 首次入场:1 秒缓动,让页面"呼吸"
  • hover 反馈:0.2 秒,绝不拖沓
  • 滚动形变:spring,中速带弹性

如果所有动画都是 0.3 秒,页面会像"工厂流水线"一样机械。节奏的不对称,才是有生命感的关键。


踩坑汇总(一表看完)

现象 根因 解决
玻璃效果在 Safari 失效 backdrop-filter 需要前缀 成对写 -webkit-backdrop-filter
TS 报 webkitBackdropFilter 红 类型定义没有厂商前缀 写进 CSS,别用 JS 设置
切窗口回来背景变白 WebGL 交换缓冲区后清空画布 preserveDrawingBuffer: true
resize 后背景模糊 没重画 加 ResizeObserver 重画
滚回顶部背景慢半拍消失 class 绑给了 spring 的衰减尾巴 class 直接由 scrollY 控制
spring 越震越厉害 积分顺序写反 先更新 v 再更新 x
光标环挡住点击 没穿透 加 pointer-events: none
触屏上有个白圈飘着 没隐藏 @media (hover: none) 隐藏

结尾:高级感的本质

回到开头的问题 ------ 为什么 deepseek.com 看起来很高级?

不是因为它用了什么"外星技术",而是因为每一个细节都克制:

  • 玻璃是"半透明"的,不是"纯白卡片"
  • 背景平时是"静"的,只在 hover 时才活过来
  • 顶栏是"弹一下"的,不是"匀速缩一下"
  • 光标环是"慢半拍"的,不是"瞬移"的
  • 所有数值来自同一套 token,全站统一

这篇文章里的每段代码,你都可以直接复制到自己的项目里跑。跑通之后,试着去改这些参数 ------ 你会发现"调参数"就是做设计,而这恰恰是工程师最容易上手、也最有成就感的地方。

如果你有兴趣进一步深挖:

  • 完整实现源码(含 5 色 curl 流体、点阵涟漪、多语言):web/src/app/ds-clone/
  • 设计意图与踩坑深度复盘:仓库 docs/ds-clone/ 下的三篇文档
  • 想要"能复用的设计规则"而不是"一篇教程"?看配套的 ds-clone-design SKILL.md ------ 把本文的所有原则编码成 agent 可直接执行的清单

希望这篇文章让你觉得"噢,原来高级感是可以用代码一点点搭出来的"。动手试试吧,期待你的作品。

相关推荐
deli00737 分钟前
32K 上下文塞满后,AI 最先忘掉什么?120 轮对话实测
前端
deli0072 小时前
手机拍灯为什么有一条条黑纹?PWM 240Hz 实测
前端
wangruofeng6 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng6 小时前
给前端开发者的 100 个设计参考网站
前端·设计
星栈10 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技10 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户479492835691510 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马12 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng13 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding14 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js