DeepSeek's Homepage Polish Comes Down to Three Techniques: Glassmorphism, a WebGL Fluid Shader, and Spring Physics
Step-by-Step Recreation of the DeepSeek Website Effect: Glassmorphism + WebGL Fluid + Spring Animation, Explained Thoroughly
First, Let's Talk: Why Do Many Official Websites "Look So Premium"?
Open deepseek.com, and your first reaction is probably "Wow, beautiful. This must require a professional design team and a bunch of fancy libraries, right?"
But if we deconstruct its homepage, we discover a counter-intuitive fact: It doesn't use any animation libraries, no 3D engine, and barely any animations. What truly makes it "premium" are three layered technical points:
- Glassmorphism — Semi-transparent frosted glass cards
- WebGL Fluid Background — That flowing blue area
- Spring Physics Animation — The top bar "elastically shrinking" on scroll
In this article, we will recreate all three of these points step by step. For every block of code, I will first explain "what it actually is," and then give you a runnable version.
Lesson 1: Glassmorphism — Three Lines of CSS to Make a Card "Come Alive"
Feel It First
Imagine placing a piece of frosted glass over a blue poster:
- The glass itself is not fully transparent — it lets you see a bit of the color underneath, but not clearly
- The glass surface has tiny particles — this is the "blur"
- The edge of the glass has a faint bright rim under light — this is the "semi-transparent border"
This is the entirety of glassmorphism. In CSS, it corresponds to three properties:
.glass-card {
background: rgba(255, 255, 255, 0.3); /* Semi-transparent white, lets the background show through */
backdrop-filter: blur(12px); /* Blurs the content behind by 12px */
border: 1px solid rgba(255, 255, 255, 0.2); /* Semi-transparent border = edge of light */
border-radius: 16px;
}
With just these three lines, you have a piece of "glass." Apply it to any background, and it will behave like real glass, "refracting" as the content beneath changes.
Going Deeper: Why 0.3 Opacity, Not #fff?
This is the point beginners overlook most easily. If the background uses pure white:
/* ❌ This has no glass feel, it's just a white card */
.glass-card { background: #fff; }
Pure white = opaque = the blue shader underneath is completely blocked. The soul of the glass feel is "semi-transparency," letting the background color show through a little, while still ensuring the foreground text is readable.
DeepSeek's approach is to extract these values into a set of "material tokens," unified across the entire site (explained in Section 3 later). For now, remember the conclusion:
| token | value | purpose |
|---|---|---|
--ds-blur-glass |
12px |
Site-wide unified blur strength (sweet spot, don't change casually) |
--ds-surface-1 |
hsla(0,0%,100%,0.3) |
General glass card |
--ds-surface-raised |
hsla(0,0%,100%,0.45) |
Layers needing to be "more solid" (e.g., top bar after scrolling) |
Pitfall Warning 1: Safari Doesn't Recognize backdrop-filter?
If you open the page in Safari on Mac and find the glass effect isn't working — this is likely the culprit:
backdrop-filter is a standard property, but Safari still requires the vendor prefix -webkit-backdrop-filter.
Therefore, everywhere you use backdrop-filter site-wide, you must write it in pairs:
.glass-card {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); /* Safari specific */
}
When writing code, don't use JS to set this property (el.style.webkitBackdropFilter = ...),
because TypeScript's type definitions don't have the webkitBackdropFilter key at all, and it will immediately show a red error.
Writing it into static CSS rules is the cleanest — CSS will automatically handle the prefix and cleanup for you.
Lesson 2: What Makes the Background "Move" Are Actually Pixels — WebGL Shader Zero-Basics Introduction
This is the hardest section of the entire article. I promise to break it down so you can copy it with your eyes closed. Let's first forget the intimidating word "shader."
Analogy: The GPU Is a Bunch of Little Robots Responsible for Coloring
Imagine there are millions of little robots inside the GPU, and there are millions of pixels on the screen, one robot responsible for one pixel. You write a "coloring instruction" (called a shader), then photocopy it millions of times and distribute it to each robot. They start working simultaneously, without interfering with each other, each calculating a color for its own pixel.
Because they are parallel, even calculating millions of times per frame is extremely fast. This is why the GPU can render in real-time.
You only need to write that "instruction" (shader), then draw a large triangle covering the screen — leave the rest to the little robots.
What Does the Coloring Instruction Look Like?
Shaders are written in GLSL. Don't be intimidated by the syntax; it's just a "small function executed once per pixel." The following is the smallest runnable example, creating a gradient from blue to white:
#version 300 es
precision mediump float;
uniform vec2 u_resolution; // Screen dimensions (pixels)
out vec4 fragColor; // Final color output to this pixel
void main() {
// gl_FragCoord is the current pixel's coordinate, divided by screen size to normalize to 0~1
vec2 uv = gl_FragCoord.xy / u_resolution;
// Mix blue and white based on the x coordinate
vec3 col = mix(vec3(0.54, 0.64, 0.84), vec3(1.0), uv.x);
fragColor = vec4(col, 1.0);
}
uv is the normalized coordinate from 0 to 1. mix(a, b, t) blends between a and b by the ratio t.
This single line draws the gradient — note, this isn't the browser drawing it for you; each pixel's little robot calculated it itself.
Core Difficulty: Making the Gradient "Not a Gradient," But Cloud-Like Color Blocks
A single-color gradient is too monotonous. The "fluid" in DeepSeek's background is actually created using noise. I'll explain it thoroughly with three more analogies:
hash: A "pseudo-random machine." Give it a coordinate, and it always spits out the same "seemingly random" number (same input always yields the same output, so the picture is stable and doesn't flicker).noise: Smoothly connects the hash results. Adjacent pixels have similar colors — you won't see snow-like noise, but rather "rolling hills."fbm(Fractal Brownian Motion): Stacks several layers of noise at different frequencies. Low-frequency layers are big waves, high-frequency layers are small ripples — stacked together, they look like the ocean surface. This is the source of the "fluid feel."
Written in GLSL, it looks like this (you can directly copy this into your project):
// Pseudo-random: same input always yields the same output
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
// Smooth noise: rolling hills
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f); // Smooth interpolation
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
);
}
// Fractal noise: stack a few layers, like the ocean surface
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;
}
Then in the main function, treat it as "terrain height" and map it to a color:
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float n = fbm(uv * 3.0); // Sample noise, get a "height"
n = smoothstep(0.3, 0.7, n); // "Hard cut" the gradient into layered color bands
vec3 col = mix(vec3(0.54, 0.64, 0.84), vec3(1.0), n);
fragColor = vec4(col, 1.0);
}
uv * 3.0 scales the noise; a larger number makes the clouds smaller and denser. Casually change this coefficient, and you'll see completely different textures — this is the joy of parameter tuning.
Making the "Static Image" Move
Adding u_time (time) into the noise coordinates makes the picture flow:
float n = fbm(uv * 3.0 + vec2(u_time * 0.05, 0.0));
Coordinates shift over time → the pattern in each frame "moves" → it looks like fluid flowing.
Pitfall Warning 2: Why Does the Background Turn White After Switching Tabs?
After writing the code above, you might encounter a strange phenomenon: The first render is normal, but once the window resizes or you switch to another tab and come back, the entire background becomes a white screen.
Reason: WebGL, by default, clears the canvas content after "swapping buffers" (displaying the drawn content on the screen). If you only drew once on the first frame, the next time the browser repaints, it can no longer find what was drawn before.
Solution: Add the option preserveDrawingBuffer: true when creating the context to preserve the canvas content:
const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true });
The cost is sacrificing a tiny bit of performance (the GPU will skip a few optimizations), but since our background "only draws one frame," it's completely negligible.
Running the Shader — A Complete, Reproducible React Component
Putting all the GLSL above together gives a runnable static fluid background component (can be embedded in any React project, or adapted to vanilla JS):
"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);
// A triangle covering the screen (two triangles forming a square)
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);
}
// Static background: only draw one frame, redraw on resize
draw(0);
const ro = new ResizeObserver(() => draw(0));
ro.observe(canvas);
return () => ro.disconnect();
}, []);
return <canvas ref={ref} className={className} />;
}
Explaining a few key points:
draw(0)only draws once — because DeepSeek's hero background is a "quiet static image" 99% of the time, it doesn't need to redraw every frame. Saves GPU.ResizeObserverredraws once when the window size changes, otherwise the image gets stretched and blurry.devicePixelRatioensures it's not blurry on high-DPI screens.
Making the Shader Flow Only "On Hover" (This Is the Key to the Premium Feel)
DeepSeek's recruitment card background has a very clever detail: It's normally static, starts flowing only when the mouse enters, and quiets down again when it leaves.
Implementation-wise, instead of continuously running an animation loop, maintain a 0~1 intensity coefficient hoverT:
let hoverT = 0, hoverTarget = 0, uTime = 0, raf = 0;
function render() {
// Approach the target by 8% each frame, giving an "acceleration" transition feel on enter/exit
hoverT += (hoverTarget - hoverT) * 0.08;
if (hoverT > 0.001) {
uTime += 0.016 * 4.0 * hoverT; // Flow speed proportional to intensity
drawFrame(uTime);
} else if (uTime !== 0) {
uTime = 0; // Return to static frame after exit
drawFrame(0);
}
// Key: Don't run RAF when not flowing, zero GPU consumption
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); });
Appreciate this design:
- On enter: hoverT eases from 0 to 1, the fluid "slowly wakes up," speed gradually increases
- On leave: hoverT eases from 1 to 0, the fluid "slowly falls asleep," finally stopping at the static frame
- When no one is hovering:
rafreturns to zero, the GPU rests completely
This is "the background is static, only coming alive when needed" — the "premium feel" mentioned earlier largely comes from here.
Lesson 3: Spring Physics Animation — The Top Bar "Elastically Shrinking" on Scroll
Now for the most elegant, and also the easiest to mess up, part.
First, See the Effect
When scrolling past 80px, the top bar shrinks from 1280px wide to 980px, and the logo area's padding changes from 0 to 16px. Moreover, the shrinking isn't linear; it has a slight "bouncy" feel.
Key detail: Its shrinking isn't "overall scaling down," but rather maxWidth narrowing + padding increasing, with left padding larger than right padding — because the logo is on the left, this "squeezes the logo towards the center," cooperating with the width shrink to create a "compressed" deformation feel. This is a detail planted by the designer.
Let's Talk About Something Simpler First: Exponential Easing ("Chasing")
Suppose you want a ball to move "smoothly" from point A to point B. The laziest way is to move 18% of the remaining distance each frame:
x += (target - x) * 0.18;
This is exponential easing. It never reaches point B (always 18% short), but gets closer every frame. The effect is "fast then slow," very silky — the cursor ring (discussed later) uses exactly this.
Advanced: Spring
Exponential easing has one drawback: it never overshoots, and has no "bounce."
DeepSeek's top bar wants that "bouncy" feel, so it uses a spring model. A spring system is determined by three things:
| Parameter | Analogy | Effect |
|---|---|---|
stiffness (k) |
Spring hardness | Larger k means stronger pullback force, faster settling |
damping (c) |
Air resistance | Larger c means less oscillation |
| Mass m | Object weight | We set m = 1 |
Physics formula (looks intimidating, but it's just two lines):
acceleration = -k × (current position - target position) - c × current velocity
velocity += acceleration × time
position += velocity × time
Translated into plain language:
- The farther from the target, the stronger the pullback force (the spring is pulling it)
- The faster the speed, the greater the resistance (air is dragging it)
- Because velocity carries "inertia" past the target, it results in "overshoot → pulled back → overshoot again → pulled back again," stopping after a few cycles
Code (can be copied directly):
const stiffness = 180;
const damping = 28;
let x = 0; // Current value 0~1
let v = 0; // Current velocity
let target = 0; // Target value
function step(now) {
const dt = Math.min(0.05, (now - lastT) / 1000); // Actual elapsed time per frame
lastT = now;
const accel = -stiffness * (x - target) - damping * v;
v += accel * dt; // Update velocity first
x += v * dt; // Then update position using the new velocity
// Map the 0~1 x to actual 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);
}
}
Pitfall Warning 3: The Update Order of v and x Cannot Be Reversed
In the code above, you must update v first, then use the new v to update x. This is called "semi-implicit Euler integration."
If you write it in reverse (x first, then v), energy will "secretly accumulate" in each frame, and the spring will oscillate more and more violently, eventually going out of control. This is the mathematical pitfall beginners fall into most easily. Just remember the four words "v first, then x."
Pitfall Warning 4 (The Most Hidden Pitfall in the Entire Article): Never Bind the Class to the Spring
Now we arrive at the famous scene of "tried back and forth several times and still couldn't get it right."
In my first implementation, the is-scrolled class for the top bar background was toggled like this:
// ❌ Wrong approach
if (p > 0.01) bar.classList.add("is-scrolled");
else bar.classList.remove("is-scrolled");
Looks very reasonable, right? But when scrolling back to the top, the background disappearance always lags "half a beat" behind the top bar recovery — the geometry has already returned to its place, but that layer of white mist on the background sticks around for another 300ms before fading.
Why? Because the spring's decay from 1 → 0 is exponential, and the last 5% (from 0.05 to 0.01) takes ages to grind through. Meanwhile, the geometry (maxWidth) is already "almost equal to" 1280px when p=0.001, visually having returned long ago. Thus:
- Geometry: p shrinks all the way to near 0, visually "instant" completion
- Class: still waiting for
p > 0.01to become false, pointlessly waiting 300ms
Fix: Completely decouple the class and the spring.
- Geometry (maxWidth/padding) → Handed to the spring for interpolation, enjoying the "bounce"
- Class (is-scrolled) → Directly determined by
scrollY, not passing through the spring
function onScroll() {
const y = window.scrollY;
target = y > 80 ? 1 : 0; // Spring only manages geometry
const scrolled = y > 80;
bar.classList.toggle("is-scrolled", scrolled); // Class directly decided by scroll position
}
And the background's fade in/out is handed to CSS transitions:
.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);
}
The result of doing this (we tested with 30ms intervals):
| Time | scrollY | Background | maxWidth |
|---|---|---|---|
| t=0 | 400 | rgba(255,255,255,0.45) glass feel |
1007px |
| t=150ms | 0 | Already transparent to 0.05 | 1124px |
| t=180ms | 0 | rgba(0,0,0,0) ✅ Background gone |
1189px |
| t=330ms | 0 | Transparent | 1279px (spring bounced back fully) |
See that: The background disappears at 180ms (driven directly by scrollY), while maxWidth is still bouncing back (driven by the spring). Because 0.18s ≈ the deformation duration, the user feels they "complete together" — in reality, the two timings are completely independent.
This is the classic example of "looks simple, but is full of pitfalls when you actually do it."
Lesson 4: DIY Cursor Ring — Adding a "Tail" to the Mouse
First, See the Effect
On DeepSeek, when the mouse moves over links/buttons, a white circle appears following the cursor, and it's not tightly attached to the cursor, but follows with a slight "lagging half a beat" feel. If it stays still for 1.6 seconds, it even fades out on its own.
Principle: The System Cursor Is Not Hidden At All
Note, it does not hide the system cursor, it just overlays a div on top of the cursor that follows it. The system cursor is always on top; this ring is just a "decoration layer."
Implementation: RAF + Exponential Easing (That "Chasing" from Lesson 1)
let mx = 0, my = 0; // Mouse real position
let rx = 0, ry = 0; // Ring current position (after easing)
function render() {
// Approach the mouse position by 18% each frame (that "tail" feel of the cursor ring)
rx += (mx - rx) * 0.18;
ry += (my - ry) * 0.18;
if (Math.abs(mx - rx) < 0.05) rx = mx; // Lock if close enough to avoid flickering
if (Math.abs(my - ry) < 0.05) ry = my;
ring.style.transform = `translate3d(${rx}px, ${ry}px, 0)`;
// Mouse hasn't moved for 1.6s → fade out
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; // Stopped = no performance cost
}
}
CSS part:
.cursorRing {
position: fixed;
top: 0; left: 0;
pointer-events: none; /* Key: let mouse events "pass through" the ring, otherwise you can't click links */
z-index: 9999;
width: 0; height: 0;
border-radius: 50%;
border: 1px solid rgb(229, 231, 235);
background: transparent;
mix-blend-mode: difference; /* Key: see below */
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; /* Center it */
border-color: transparent;
background: #fff;
}
Two Design Details Worth Mentioning
Detail 1: mix-blend-mode: difference
This is the key to making this ring "work on both black and white." Its principle is "inverted color":
- The ring is white
#fff - On a dark background, difference blending → displays as white ✅
- On a white background, difference blending → displays as black ✅
No need to write two sets of themes, one style handles all backgrounds. This is one of the underlying logics of "premium feel" — less is more.
Detail 2: Must Hide on Touch Devices
@media (hover: none), (pointer: coarse) {
.cursorRing { display: none; }
}
Otherwise, mobile users will see a "white circle floating mid-air, not moving," which is extremely weird.
Lesson 5: Design Tokens — Premium Feel Doesn't Come from Inspiration, It Comes from "Discipline"
By this point, you'll notice that the colors and blur values used across the three lessons are just those few:
- Semi-transparent white:
0.3/0.45 - Blur:
12px - Border:
0.2
This is the value of a design system. Extract them into variables (called custom properties / tokens in CSS), reuse them site-wide, and there are three benefits:
- Change one place, takes effect site-wide — Want to brighten the glass? Change one variable.
- Enforces consistency — No more runaway situations where "this card is 12px blur, that one is 20px."
- High readability — Writing
var(--ds-blur-glass)in code expresses intent more clearly than writingblur(12px).
A set of tokens you can directly copy (same structure as DeepSeek's):
:root {
/* Glass material */
--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);
/* Text */
--ds-text-primary: #1e232c;
--ds-text-primary-bluish: #152443; /* Slogan specific, blue-ish black */
--ds-text-secondary: rgba(0, 0, 0, 0.7);
/* Brand colors */
--ds-brand: #4d6bfe;
--ds-brand-deep: #3a65c2;
/* Border radius */
--ds-radius-pill: 100px;
--ds-radius-card: 24px;
--ds-radius-panel: 16px;
}
Lesson 6: Making "Restraint" Concrete — Three Disciplines
After finishing all the techniques above, let's finally talk about something more important than technology. Looking at DeepSeek's hero, you'll find it "can do everything, but chooses not to":
Discipline 1: Don't Add Displacement on Hover
Many websites like to use transform: translateY(-4px) when hovering over cards. DeepSeek doesn't do this — the card just stays still, letting the shader's flow and the cursor ring handle the "being noticed" feedback. Displacement would actually destroy the "sense of stability."
Discipline 2: The Background Is the Protagonist, Text Remains "Still"
The slogan is 46px, letter-spacing 0.4em, deep blue, never enlarges, never shakes. What's truly "alive" is the background shader. This contrast of "still + moving" is what makes the text appear premium.
Discipline 3: Slow Entrance, Fast Response
- First entrance: 1-second easing, letting the page "breathe"
- Hover feedback: 0.2 seconds, absolutely no sluggishness
- Scroll deformation: spring, medium speed with bounce
If all animations were 0.3 seconds, the page would feel as mechanical as a "factory assembly line." Asymmetric rhythm is the key to a sense of life.
Pitfall Summary (See It All in One Table)
| Phenomenon | Root Cause | Solution |
|---|---|---|
| Glass effect fails in Safari | backdrop-filter needs prefix |
Write -webkit-backdrop-filter in pairs |
TS reports red for webkitBackdropFilter |
Type definitions lack vendor prefix | Write in CSS, don't set via JS |
| Background turns white after switching tabs | WebGL clears canvas after buffer swap | preserveDrawingBuffer: true |
| Background blurry after resize | Didn't redraw | Add ResizeObserver to redraw |
| Background disappears half a beat late when scrolling to top | Class bound to spring's decay tail | Control class directly by scrollY |
| Spring oscillates more and more violently | Integration order reversed | Update v first, then x |
| Cursor ring blocks clicks | Not pass-through | Add pointer-events: none |
| White circle floats on touchscreen | Not hidden | @media (hover: none) to hide |
Conclusion: The Essence of Premium Feel
Back to the opening question — why does deepseek.com look premium?
It's not because it uses some "alien technology," but because every single detail is restrained:
- The glass is "semi-transparent," not a "pure white card"
- The background is normally "still," only coming alive on hover
- The top bar "bounces a bit," not "shrinks linearly"
- The cursor ring "lags half a beat," not "teleports"
- All values come from the same set of tokens, unified site-wide
Every block of code in this article can be directly copied into your own project and run. After getting it running, try changing these parameters — you'll find that "tuning parameters" is doing design, and this is precisely the place where engineers find it easiest to start and most rewarding.
If you're interested in digging deeper:
- Complete implementation source code (including 5-color curl fluid, dot matrix ripples, multi-language):
web/src/app/ds-clone/- Design intent and in-depth pitfall review: Three docs under the repo's
docs/ds-clone/- Want "reusable design rules" rather than "a tutorial"? See the companion
ds-clone-designSKILL.md — encoding all the principles from this article into a checklist directly executable by an agent
I hope this article makes you feel "Oh, so premium feel can be built up bit by bit with code." Give it a try, I look forward to your work.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Great article! Learned a lot.