普通视图

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

CSS 阴影生成器:从单层到多层叠加的艺术

2026年5月4日 23:16

box-shadow 的六个参数

先来拆解一下语法:

box-shadow: inset x-offset y-offset blur spread color;

六个参数,除了 inset 可选,其他都可以排列组合:

  • inset:内阴影,默认是外阴影
  • x-offset:水平偏移,正值向右
  • y-offset:垂直偏移,正值向下
  • blur:模糊半径,值越大越模糊
  • spread:扩展半径,正值扩大阴影,负值缩小
  • color:颜色,支持 hex、rgba、hsla

最容易被忽略的是 spread。很多人以为阴影只能往外扩,其实负值可以让阴影缩小,这在做一些微妙效果时很有用。

为什么好的阴影都要多层?

看看 Apple 官网的卡片阴影:

/* 看起来是一个阴影,实际是三层 */
box-shadow:
  0 0 0 1px rgba(0,0,0,0.05),    /* 微妙的边框效果 */
  0 2px 4px rgba(0,0,0,0.1),      /* 近处的硬阴影 */
  0 8px 16px rgba(0,0,0,0.1);     /* 远处的软阴影 */

分层的原因是模拟真实光线:

  1. 近处阴影:偏移小、模糊小、颜色深 → 光源近
  2. 远处阴影:偏移大、模糊大、颜色淡 → 光源远
  3. 边缘描边:模糊为 0 的阴影可以当边框用,比 border 更灵活

这种叠加能让卡片看起来"浮"在背景上,而不是贴着。

用 JavaScript 实现多层阴影生成器

核心逻辑其实就是一个数组管理:

interface ShadowLayer {
  id: string
  offsetX: number
  offsetY: number
  blur: number
  spread: number
  color: string
  inset: boolean
}

function generateShadowCSS(layers: ShadowLayer[]): string {
  return layers
    .map(l => `${l.inset ? 'inset ' : ''}${l.offsetX}px ${l.offsetY}px ${l.blur}px ${l.spread}px ${l.color}`)
    .join(', ')
}

用户可以动态添加/删除层,每层独立控制所有参数。最终生成的 CSS 用逗号连接:

box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.06);

几个实用技巧

1. 阴影做边框

当元素有 border-radius 时,border 会显得生硬。用阴影模拟边框更自然:

.card {
  border-radius: 12px;
  box-shadow: 0 0 0 1px rgba(0,0,0,0.1);
}

好处是阴影会跟着圆角走,不会出现直角边框。

2. 内阴影做凹陷效果

.input:focus {
  box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}

输入框聚焦时加个内阴影,视觉上像"按下去"的感觉。

3. 多色阴影做霓虹效果

.neon {
  box-shadow:
    0 0 10px #ff00de,
    0 0 20px #ff00de,
    0 0 40px #ff00de,
    0 0 80px #ff00de;
}

多层同色阴影叠加,模糊半径递增,就能做出霓虹灯效果。

颜色透明度的重要性

阴影颜色几乎都用 rgba,很少用纯黑色。原因是真实世界没有纯黑的阴影,都是带环境色的。

透明度的选择也有讲究:

  • 淡阴影rgba(0,0,0,0.05) ~ 0.1 → 高光环境、白色背景
  • 中等阴影rgba(0,0,0,0.15) ~ 0.25) → 普通卡片、按钮
  • 深阴影rgba(0,0,0,0.3) ~ 0.5) → 模态框、弹窗

Tailwind CSS 的阴影灰度就是这样设计的,淡灰为主,避免突兀。

性能小坑

box-shadow 会触发重绘,大面积多层阴影可能影响性能。几个建议:

  1. 避免动画阴影:不要在 transition 里动画 box-shadow,用 transform: scale() 模拟
  2. 减少层数:3-4 层足够,再多肉眼也分不清
  3. will-change:对固定阴影元素加 will-change: box-shadow 提前告知浏览器
.card:hover {
  will-change: box-shadow;
  box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}

在这里插入图片描述

工具推荐

基于这些原理做了个在线工具:CSS 阴影生成器

特点:

  • 多层阴影叠加,支持增删层
  • 实时预览效果
  • 一键复制 CSS 代码
  • 支持内阴影模式

不用手写 CSS,拖拖滑块就能调出想要的阴影效果。调好后直接复制代码,粘贴到项目里用。


相关工具:边框生成器 | 按钮生成器

昨天以前首页

从零实现 GIF 制作工具:LZW 压缩与 Median Cut 色彩量化

2026年5月1日 01:42

GIF 制作工具,原本以为直接用现成的库就完事了,结果发现纯前端实现更有意思。这篇文章聊聊 GIF 格式的核心技术:LZW 压缩和 Median Cut 色彩量化。
在这里插入图片描述

为什么 GIF 这么难搞?

GIF 格式诞生于 1987 年,那时候的设计理念跟现在完全不同。最大的坑在于:GIF 只支持 256 色。现在的图片动辄几百万色,要塞进 256 色的框框里,还得保持画质,这就是色彩量化的难题。

另一个坑是 LZW 压缩。GIF 用的 LZW 算法是专利保护的(2003 年过期),但更重要的是,LZW 的实现细节很容易踩坑。比如码表溢出怎么办?Clear Code 什么时候发?这些细节文档里写得含糊,得靠实验摸索。

Median Cut:把几百万色砍到 256 色

Median Cut 是经典的色彩量化算法,核心思想很简单:把颜色空间切成 256 个方块,每个方块取中心点作为代表色。

算法步骤

function medianCut(pixels, maxColors) {
  // 1. 统计所有颜色及其出现频率
  const colorMap = new Map()
  for (let i = 0; i < pixels.length; i += 4) {
    const key = `${pixels[i]},${pixels[i+1]},${pixels[i+2]}`
    colorMap.set(key, (colorMap.get(key) || 0) + 1)
  }
  
  // 2. 初始化:所有颜色放进一个桶
  const buckets = [{
    entries: Array.from(colorMap.entries()),
    rMin: 0, rMax: 255,
    gMin: 0, gMax: 255,
    bMin: 0, bMax: 255
  }]
  
  // 3. 反复切分,直到桶数达到 maxColors
  while (buckets.length < maxColors) {
    // 找范围最大的桶
    const bucket = findWidestBucket(buckets)
    if (!bucket) break
    
    // 沿最长边切分
    const [left, right] = splitBucket(bucket)
    buckets.splice(buckets.indexOf(bucket), 1, left, right)
  }
  
  // 4. 每个桶取加权平均色作为调色板
  return buckets.map(b => computeAverage(b))
}

关键细节

为什么要按出现频率加权? 因为颜色出现的次数越多,对视觉影响越大。如果一个像素只出现一次,它被量化错了也无所谓;但背景色要是偏了,整张图都难看。

切分策略的选择:标准 Median Cut 按颜色数量均分,但更好的做法是按像素数量均分。这样可以避免一个桶里塞了几百万像素,另一个桶只有几个像素的情况。

LZW 压缩:GIF 的灵魂

LZW 是一种字典编码算法,核心思想是用短编码代替重复出现的字符串。GIF 的 LZW 有几个特殊点:

1. 变长编码

LZW 的编码长度是动态增长的。初始码长是 minCodeSize + 1,随着字典增大,码长逐步增加到 12 位。一旦字典满了(4096 项),就发 Clear Code 重置字典。

function packBits(codes, minCodeSize) {
  const clearCode = 1 << minCodeSize
  const endCode = clearCode + 1
  
  let codeSize = minCodeSize + 1
  let nextCode = endCode + 1
  
  for (const code of codes) {
    // 把编码塞进位缓冲区
    bitBuffer |= (code << bitCount)
    bitCount += codeSize
    
    // 每凑够 8 位就输出一个字节
    while (bitCount >= 8) {
      bytes.push(bitBuffer & 0xFF)
      bitBuffer >>>= 8
      bitCount -= 8
    }
    
    // 字典满了,发 Clear Code
    if (nextCode >= 4096) {
      codes.push(clearCode)
      nextCode = endCode + 1
      codeSize = minCodeSize + 1
    }
    // 码长增长
    else if (nextCode >= (1 << codeSize) && codeSize < 12) {
      codeSize++
    }
  }
}

2. 字典构建

LZW 的字典是边压缩边构建的。每次输出一个编码,就把"当前编码 + 下一个像素"加入字典。

function lzwEncode(indexedPixels, minCodeSize) {
  const dict = new Map()
  let w = indexedPixels[0]
  
  for (let i = 1; i < indexedPixels.length; i++) {
    const k = indexedPixels[i]
    
    // w+k 在字典里,继续扩展
    if (dict.get(w)?.has(k)) {
      w = dict.get(w).get(k)
    }
    // w+k 不在字典里,输出 w,把 w+k 加入字典
    else {
      codes.push(w)
      if (!dict.has(w)) dict.set(w, new Map())
      dict.get(w).set(k, nextCode++)
      w = k
    }
  }
  
  codes.push(w)
  return codes
}

3. 性能优化

原始 LZW 实现用 w+k 作为字典键,字符串拼接很慢。优化方案是用嵌套 Map:第一层 Map 的键是前缀编码,第二层 Map 的键是后缀像素值。这样查找从 O(n) 降到 O(1)。

GIF 文件结构

GIF 文件是按块组织的,主要包含:

GIF89a Header
Logical Screen Descriptor
Netscape Application Extension (循环播放)
┌─ Graphics Control Extension (延迟、透明)
│  Image Descriptor
│  Local Color Table
│  LZW Image Data
└─ (重复每一帧)
GIF Trailer

延迟时间的坑

GIF 的延迟单位是 10 毫秒,不是 1 毫秒。而且很多播放器会强制最小延迟为 20ms(2 个单位),所以你设 10ms 实际播放可能是 20ms。

循环播放

GIF89a 标准本身不支持循环播放,是 Netscape 加的扩展。循环次数 0 表示无限循环,1 表示播放 1 次(总共播放 2 次)。这个坑我踩了好久。

实战经验

做 JsonKit 的 GIF 制作工具时,遇到几个性能问题:

色彩量化太慢:原始实现遍历所有像素找最近色,O(width × height × paletteSize)。优化方案是用 KD-Tree 或预先建立颜色查找表。

LZW 压缩太慢:字典查找是瓶颈。用嵌套 Map 替代字符串拼接后,速度提升了 10 倍。

内存爆炸:处理大图时,Canvas 的 getImageData 会返回巨大的数组。解决方案是分块处理,或者用 Web Worker 避免阻塞主线程。

最终效果

在这里插入图片描述

相关工具

总结

GIF 格式虽然古老,但背后的技术很有意思。LZW 压缩是早期无损压缩的经典算法,Median Cut 是色彩量化的基石。理解这些原理,不仅能写出更好的 GIF 工具,对理解现代图片格式(WebP、AVIF)也有帮助。

完整代码在 JsonKit 的 GIF Maker 工具里,欢迎试用。

❌
❌