✨Effect Lab
Texture质感

Why broken looks expensive为什么「坏掉」看起来很贵

A perfectly clean image reads as a render. Add scanlines, grain, or a burst of slice displacement and it reads as a recording of something real. These four effects are the cheapest way to buy that feeling.一张完美干净的图读起来像渲染。加上扫描线、颗粒,或者一阵切片位移,它就读起来像「某个真实东西的录像」。这四个效果,是买到这种感觉最便宜的方式。

Glitch is three cheap lies at once

A convincing glitch is not one distortion, it is three stacked: horizontal slices displaced sideways, colour channels separated, and the whole thing interrupted for a few frames.

The slices come from clip-path on duplicated layers. Each copy is clipped to a horizontal band and shifted by a few pixels. You do not animate the clip shape; you regenerate the bands on each glitch tick, so the slices move around rather than sliding predictably:

The colour separation comes free if you have two extra copies with text-shadow or a channel filter in red and cyan. That fringing is what a bad analogue signal actually does, which is why red/cyan reads as "broken video" and red/green reads as "broken".

The interruption is the part people forget. A continuous glitch looks like a filter; an intermittent one looks like a fault. Keeping it off for 80% of the time and bursting for 120ms is the difference between "this is styled" and "something went wrong".

slice + channel split
.glitch { position: relative; }
.glitch::before, .glitch::after {
  content: attr(data-text);
  position: absolute; inset: 0;
  clip-path: inset(20% 0 55% 0);       /* a horizontal band */
  animation: shift 2.6s steps(1) infinite;
}
.glitch::before { color: #f0f; left: 2px; }
.glitch::after  { color: #0ff; left: -2px; }
@keyframes shift {
  0%, 92%, 100% { transform: none; }
  93% { transform: translateX(-6px); }
  96% { transform: translateX(5px); }
}

steps(1) is essential: it makes the value jump instead of interpolating, which is what a digital fault does.

Scanlines are a repeating gradient, not a texture file

The CRT look is three layers: horizontal scanlines, a soft vertical vignette, and a slight flicker. None of them needs an image.

The scanlines are repeating-linear-gradient with a period of two or three pixels and a low-alpha dark stripe. At 1px period it turns into a grey wash on most displays; at 4px it reads as a venetian blind. Two to three is the range where it reads as a phosphor grid.

The flicker should be very subtle and irregular. A CSS animation with a 0.03 opacity swing at an odd duration (not a round number like 4s) is enough — round numbers make the loop audible to the eye, and the eye is very good at spotting a 4-second cycle.

The curvature is the one part that is genuinely hard. Real CRT geometry is barrel distortion, which CSS cannot do. The demos here fake it with a large border-radius on the container plus an inset shadow, and that is enough because nobody is looking at the corners.

scanlines + vignette
.crt::after {
  content: '';
  position: absolute; inset: 0;
  pointer-events: none;
  background:
    repeating-linear-gradient(
      to bottom,
      rgba(0,0,0,.28) 0 1px,
      transparent     1px 3px
    ),
    radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.6) 100%);
  animation: flicker 6.31s infinite steps(2);
}
@keyframes flicker { 50% { opacity: .92; } }

Moiré comes from two grids, and you need both to be irregular

The interference pattern is not drawn. It emerges when two periodic patterns overlap at a slight angle — the visible bands are an artefact of sampling, not a shape anyone designed.

Two repeating-linear-gradient layers, rotated a couple of degrees relative to each other, produce it immediately. The bands get wider as the angle gets smaller: at 1° the fringes are huge, at 10° they are fine and busy. That relationship is the only control you need.

The animation is a very slow rotation of one layer. Because the fringe spacing is so sensitive to angle, a 3-degree rotation over twenty seconds sweeps the whole pattern through several cycles, and the result is a pattern that flows continuously without any element actually moving far.

The trap: at some angles the pattern aliases against the screen's pixel grid and produces colour fringing that looks like a rendering bug. If that happens, nudge the angle by 0.3° or change the stripe period by a fraction of a pixel — the artifacting is angle-dependent, not a bug in your CSS.

two grids, 0.8° apart
.moire {
  background-image:
    repeating-linear-gradient(45deg, #000 0 2px, transparent 2px 6px),
    repeating-linear-gradient(45.8deg, #000 0 2px, transparent 2px 6px);
  animation: drift 24s linear infinite;
}
@keyframes drift {
  to { background-position: 0 0, 40px 40px; }
}

Grain has to move, and it has to be cheap

The noise demo generates its texture per frame, and the naive way — a full-size ImageData with a random value per pixel — is far too slow at any real resolution.

Two ways to make it affordable. Generate a small tile (say 128×128) once, and tile it with a random offset each frame. Or generate a low-resolution noise buffer and scale it up with imageSmoothingEnabled = false, so you get chunky grain that costs a fraction of the pixels.

The offset matters more than the pattern. A static noise overlay reads as dirt on the screen; noise that redraws at a different offset every frame reads as film. It is the temporal change, not the spatial pattern, that carries the meaning.

For a still image, <feTurbulence> in an SVG filter gives you real fractal noise for one line of markup. It is not cheap enough to animate at 60fps on a large area, which is why the demo uses a canvas instead.

tiled grain, per-frame offset
// small tile, redrawn at a random offset — cheap and it moves
const tile = 128;
const img = noiseCtx.createImageData(tile, tile);
for (let i = 0; i < img.data.length; i += 4) {
  const v = Math.random() * 255;
  img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
  img.data[i + 3] = 26;                       // very low alpha
}
noiseCtx.putImageData(img, 0, 0);

// each frame: stamp the tile at a random offset
const ox = Math.random() * tile | 0, oy = Math.random() * tile | 0;
ctx.fillStyle = ctx.createPattern(noiseCanvas, 'repeat');
ctx.save();
ctx.translate(-ox, -oy);
ctx.fillRect(ox, oy, W, H);
ctx.restore();

The rule behind all four: damage should be uneven

Every one of these looks convincing for the same reason: the damage is not uniform. The glitch bursts instead of pulsing. The scanlines are interrupted by a vignette. The moiré bands are wider in some places than others. The grain redraws differently every frame.

Uniform damage reads as a filter — a gradient across the whole image, a constant noise level, a steady pulse. Uneven damage reads as a recording of a physical process. That is the entire difference, and it is why adding "just a bit" of these effects usually makes an image worse: you have added a uniform filter, not a fault.

The practical version: keep the average intensity low and the variance high. Ninety percent of frames should be almost clean, and then one frame should be visibly wrong.

Where to take this

Glitch slices, scanlines, interference and grain are all damage artifacts, and all four are cheaper than they look. The rule that makes them work is unevenness — a glitch that is continuous is just a filter. Spike it, then leave it alone.

故障效果同时撒三个便宜的谎

一个像样的 glitch 不是一种扭曲,是三种叠起来:横向切片被侧移、颜色通道分离、以及整体被中断几帧。

切片来自复制层上的 clip-path。每一份被裁成一条横带,再平移几个像素。你不去动裁剪形状,而是在每次故障跳动时重新生成横带,于是切片是在乱跳,而不是可预测地滑:

颜色分离是白送的——再做两份副本,用 text-shadow 或通道滤镜染成红和青。那圈镶边正是坏掉的模拟信号会干的事,所以红/青读作「坏掉的视频」,而红/绿读作「坏掉了」。

中断是最容易被忘掉的部分。持续的故障看起来像个滤镜;断续的故障才像一次事故。八成时间关掉、每次爆 120 毫秒,这就是「这是风格」和「出问题了」之间的差别。

切片 + 通道分离
.glitch { position: relative; }
.glitch::before, .glitch::after {
  content: attr(data-text);
  position: absolute; inset: 0;
  clip-path: inset(20% 0 55% 0);       /* 一条横带 */
  animation: shift 2.6s steps(1) infinite;
}
.glitch::before { color: #f0f; left: 2px; }
.glitch::after  { color: #0ff; left: -2px; }
@keyframes shift {
  0%, 92%, 100% { transform: none; }
  93% { transform: translateX(-6px); }
  96% { transform: translateX(5px); }
}

steps(1) 是关键:它让数值跳变而不是插值,数字故障正是跳变的。

扫描线是一个重复渐变,不是一张贴图

CRT 的观感由三层构成:横向扫描线、柔和的四角压暗、以及轻微的闪烁。一层都不需要图片。

扫描线是周期两三像素的 repeating-linear-gradient,一条低透明度的暗纹。周期 1px 时在多数显示器上糊成一层灰;4px 时读作百叶窗。两到三像素,才是「荧光粉栅格」的区间。

闪烁必须非常轻、而且不规则。一个 0.03 透明度摆动、时长取个奇数(别用 4s 这种整数)的 CSS 动画就够了——整数会让循环被眼睛听出来,而眼睛对 4 秒周期极其敏感。

弧度是唯一真难的部分。真实 CRT 是桶形畸变,CSS 做不了。本站的示例用容器上一个大 border-radius 加内阴影糊过去,而这已经够了,因为没人在看四个角。

扫描线 + 四角压暗
.crt::after {
  content: '';
  position: absolute; inset: 0;
  pointer-events: none;
  background:
    repeating-linear-gradient(
      to bottom,
      rgba(0,0,0,.28) 0 1px,
      transparent     1px 3px
    ),
    radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.6) 100%);
  animation: flicker 6.31s infinite steps(2);
}
@keyframes flicker { 50% { opacity: .92; } }

莫尔条纹来自两组栅格,而且两组都得「不整齐」

干涉花纹不是画出来的。它是两组周期性图案以极小夹角叠加时自己涌现的——你看到的那些带子,是采样的产物,不是谁设计的形状。

两层 repeating-linear-gradient,相对旋转两三度,它立刻就出现了。夹角越小,带子越宽:1° 时条纹巨大,10° 时细密杂乱。这个关系就是你唯一需要的控件。

动画是让其中一层极慢地旋转。因为条纹间距对角度极其敏感,二十秒转 3 度,就能让整幅图案扫过好几个周期——结果是图案在连续流动,而任何元素实际上都没移动多远。

坑在这里:某些角度下图案会和屏幕像素栅格发生混叠,产生彩色的镶边,看起来像渲染 bug。真遇到了,把角度挪 0.3°,或者把条纹周期改零点几像素——这个瑕疵是角度依赖的,不是你 CSS 写错了。

两组栅格,差 0.8°
.moire {
  background-image:
    repeating-linear-gradient(45deg, #000 0 2px, transparent 2px 6px),
    repeating-linear-gradient(45.8deg, #000 0 2px, transparent 2px 6px);
  animation: drift 24s linear infinite;
}
@keyframes drift {
  to { background-position: 0 0, 40px 40px; }
}

颗粒必须会动,而且必须便宜

噪点示例每帧生成纹理,而最直觉的做法——一张全尺寸 ImageData、每个像素一个随机值——在任何真实分辨率下都慢得不能用。

两个办法让它便宜。生成一小块(比如 128×128)存着,每帧用随机偏移去铺。或者生成一张低分辨率噪声、用 imageSmoothingEnabled = false 放大,得到粗颗粒,而成本只是几分之一的像素。

偏移比图案更重要。静止的噪声覆盖层读作「屏幕脏了」;每帧换个偏移重画的噪声读作「胶片」。承载意义的是时间上的变化,不是空间上的图案。

如果只是静图,SVG 滤镜里的 <feTurbulence> 一行标记就能给你真正的分形噪声。但它在大面积上跑不到 60fps,所以示例用的是 canvas。

平铺颗粒 + 逐帧偏移
// 小块噪声,每帧换一个随机偏移——便宜,而且它在动
const tile = 128;
const img = noiseCtx.createImageData(tile, tile);
for (let i = 0; i < img.data.length; i += 4) {
  const v = Math.random() * 255;
  img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
  img.data[i + 3] = 26;                       // alpha 非常低
}
noiseCtx.putImageData(img, 0, 0);

// 每帧:把小块以随机偏移盖上去
const ox = Math.random() * tile | 0, oy = Math.random() * tile | 0;
ctx.fillStyle = ctx.createPattern(noiseCanvas, 'repeat');
ctx.save();
ctx.translate(-ox, -oy);
ctx.fillRect(ox, oy, W, H);
ctx.restore();

四个效果背后的同一条规则:损伤要不均匀

这四个之所以都像,是因为同一个理由:损伤不均匀。故障是爆发而不是脉动。扫描线被四角压暗打断。莫尔的条纹有的地方宽有的地方窄。颗粒每帧重画都不一样。

均匀的损伤读作滤镜——整张图铺一层渐变、恒定的噪声强度、稳定的脉冲。不均匀的损伤读作「某个物理过程的记录」。差别就这一条,也是为什么「只加一点点」这些效果通常让画面更糟:你加的是一个均匀滤镜,不是一个故障。

可操作的版本:强度平均值压低,方差拉高。九成的帧几乎干净,然后某一帧明显不对。

接下来可以怎么用

故障切片、扫描线、干涉和颗粒都是损伤痕迹,而且四个都比看起来便宜。让它们成立的那条规则是不均匀——一个持续不断的故障,只是一个滤镜。让它偶尔爆一下,然后别去碰它。

Related demos相关示例

Each one runs on its own page, with the source shown below the stage.每个都在独立页面实时运行,舞台下方附完整源码。

Every effect on this site is a single self-contained file. Copy it, change one number, see what happens.本站每个特效都是单个自包含文件。复制走,改一个数字,看会发生什么。

Browse all 131 effects浏览全部 131 个特效