# 手把手复刻 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) 是在 ab 之间按比例 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:vx 的更新顺序不能反

上面代码里必须先更新 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 可直接执行的清单

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

相关推荐
名字还没想好☜1 小时前
Next.js 用 cookies()/headers() 读请求信息:动态渲染触发、缓存失效与在 Server Action 里读写 cookie
前端·javascript·缓存·react·next.js·app router
今日无bug1 小时前
从「拿来主义」到「亲手造轮子」:2 种 MCP 文件服务器写法对比
前端·node.js·mcp
xcyxiner1 小时前
flutter 运行到模拟器上
android·前端·flutter
用户921080262861 小时前
从 COT 到 ThoughtChain:AI 应用为什么需要展示“思考过程”
前端
天真小巫1 小时前
2026.8.23总结
前端·html
PedroQue991 小时前
RouterLink v2.5.0:H5端原生能力全面回归
前端·uni-app
八角丶1 小时前
Node.js 异步上下文详解(实验驱动)
前端·node.js
用户2181697049301 小时前
Flutter (二十五)视频播放
前端
半个落月1 小时前
在浏览器里运行 DeepSeek-R1:推理、流式输出与停止生成(二)
前端·人工智能·react.js