我写 HueTexture 的起点很简单:一张封面图已经决定了页面的气质,但大多数网页仍然让背景、按钮、卡片和文字各自维护一套颜色。换图之后,设计系统并不会自动跟着变化。我想做的,是让图片本身成为整页界面的颜色输入。
于是我把这个想法做成了一个 Web Component:输入一张图片,组件在浏览器中提取主色,生成 light 和 dark 两套语义颜色,处理出带染色、饱和度和颗粒感的背景,并把结果作为 CSS 变量交给组件内部的内容。桌面端还可以通过鼠标移动和点击,让纹理产生类似纸面压痕与涟漪的反馈。
HueTexture 目前仍是一个很年轻的 0.1.x 项目。这篇文章不是一份只展示“怎么调用”的 README,而是一次完整的设计复盘:它想解决什么、为什么选择 Svelte 5 和 Web Component、图片如何变成 OKLCH 主题、Canvas 与 WebGL 各自负责什么,以及当前实现还有哪些诚实的性能边界。
我想解决的不是换背景,而是让图片成为设计系统#
很多所谓的“图片主题”只做了一件事:从图片中挑一个颜色,设置到按钮上。问题是,一个可用的界面从来不只有一个颜色。
它至少需要这些关系:
- 页面背景和卡片表面要有层级;
- 前景文字必须在不同背景上保持可读;
- primary、secondary、muted 和 accent 要承担不同语义;
- border、input 和 focus ring 需要足够清楚,但不能喧宾夺主;
- 浅色和深色模式要共享同一种色彩气质,却拥有不同的亮度结构;
- 图片加载、颜色提取或 WebGL 失败时,正文仍然必须可读。
所以 HueTexture 的目标不是“把原图颜色原封不动塞进 CSS”,而是建立一条稳定的转换流水线:
输入图片
├─ node-vibrant 提取 6 类主色
│ └─ 映射为 light / dark 两套 OKLCH 语义 token
│
├─ Canvas 调整 saturate / tint / grain
│ └─ 输出最终 cover 背景位图
│
└─ Pointer 输入
└─ 低分辨率压力场 → WebGL 位移 → 纸面按压反馈
最终汇合到 <hue-texture> host
├─ 注入 --background / --primary / --card / --ring ...
├─ 渲染背景与保护遮罩
└─ slot 内的任意框架内容直接继承主题
这里最重要的产品判断是:照片决定色相与情绪,设计系统决定亮度层级与可读性。
明确的非目标#
HueTexture 并不试图成为一套完整 UI 组件库,也不会替使用者决定布局、字号或间距。它只负责把图片转换成一个稳定的视觉上下文。
当前实现也不是照片编辑器:它没有曲线、局部蒙版或复杂调色工具。它的输出面向网页背景和语义 token,而不是导出一张用于摄影后期的成片。
为什么我选择 Svelte 5 Web Component#
我不希望这个组件只能在某一个框架里使用。博客是 Next.js,但组件本身应该也能放进普通 HTML、Vue、Svelte 或其他 React 项目。
Web Component 正好提供了一个足够小的跨框架边界:
<hue-texture
src="/photo.jpg"
theme="auto"
overlay="gradient"
tint="0.3"
saturate="0.8"
grain="0.15"
press="soft"
>
<article class="card">
<h2>Content inherits the image-driven theme</h2>
</article>
</hue-texture>
组件内部使用 Shadow DOM 管理背景、overlay 和 press Canvas;业务内容通过 slot 进入组件。颜色则不封闭在 Shadow DOM 里,而是写到 host 的 CSS 自定义属性上,再自然继承给 slot 内容。
内部结构大致是:
hue-texture host
└─ shadow root
└─ section.surface
├─ canvas.press-layer
├─ div.overlay
└─ div.content
└─ slot
我使用 Svelte 5 的 $host()、$props()、$state()、$derived.by() 和 $effect() 来组织这些状态。它的价值不是少写几行代码,而是能把“图片变化”“主题变化”“纹理处理完成”“press source 更新”拆成独立反应链,同时保留自定义元素这一稳定的外部接口。
导入模块同时完成注册#
npm 包以 ES Module 发布。业务侧只需要:
import 'huetexture'
构建产物加载后会注册 hue-texture 自定义元素。package.json 中保留了:
{
"sideEffects": true
}
这是一个很小但重要的设置。注册自定义元素本身就是模块副作用;如果错误地标记为无副作用,打包器可能在 tree-shaking 时把这次 import 删除。
最小使用方式#
安装:
npm install huetexture
普通 HTML 或任何支持自定义元素的框架都可以直接消费这些变量:
.card {
border: 1px solid var(--border);
background: color-mix(in oklch, var(--card) 82%, transparent);
color: var(--card-foreground);
}
.primary-button {
background: var(--primary);
color: var(--primary-foreground);
}
当前主要属性如下:
| 属性 | 默认值 | 作用 |
|---|---|---|
src | 空字符串 | 图片地址 |
tint | 0.3 | 向主色混合的强度,范围 0–1 |
saturate | 0.8 | 饱和度倍率,范围 0–2 |
grain | 0.15 | 颗粒强度,范围 0–1 |
overlay | none | none、gradient、uniform 或 blur |
overlay-opacity | 自动 | 显式设置遮罩透明度 |
theme | auto | auto、light 或 dark |
press | soft | 开启按压反馈,设为 none 可关闭 |
tile-size | 256 | 历史属性名,当前主要参与输出尺寸选择 |
最后一项需要特别说明:早期原型把处理结果当作 tile,因此留下了 tile-size 这个名称;当前版本实际生成的是完整 cover 位图,组件 CSS 使用 background-repeat: no-repeat 和 background-size: cover。接口名称和当前语义已经出现偏差,后续更合理的做法是迁移为 texture-size 或 max-edge。
从图片提取六类主色#
颜色提取使用浏览器版 node-vibrant:
const palette = await Vibrant.from(image)
.maxColorCount(96)
.quality(3)
.getPalette()
HueTexture 读取六类标准 swatch:
Vibrant
LightVibrant
DarkVibrant
Muted
LightMuted
DarkMuted
每个 swatch 不只保存 HEX,还会保留 RGB、population、OKLCH,以及 node-vibrant 给出的标题和正文建议色。组件还会按照 population 对有效 swatch 做加权,得到一个 average 颜色。
当图片无法提取有效色板时,组件不会让整个主题链断掉,而是回退到固定蓝灰色 #798da8。这份 fallback 会复制成六类 swatch,保证后面的 token 映射永远有完整输入。
为什么不直接把 Vibrant 写成 primary#
如果直接复制原图 RGB,一张非常亮的照片可能让浅色背景失去层级,一张很暗的照片也可能让深色卡片和正文粘在一起。颜色“像照片”不等于界面“能使用”。
因此我为 light 和 dark 各维护一套固定亮度骨架,然后只从图片继承色相和受限色度。
以语义角色为例:
| Token | 来源 swatch | 设计意图 |
|---|---|---|
background / card | LightMuted | 低色度表面 |
primary / ring | Vibrant | 主要强调色 |
secondary | DarkVibrant | 次级强调色 |
muted | Muted | 弱化信息 |
accent | LightVibrant | 轻强调表面 |
border / input | DarkMuted | 结构边界 |
转换时保留主题骨架的亮度 L,使用图片的色相 H,并把色度 C 限制在不同角色允许的范围:
const base = {
l: mode === 'dark' ? DARK_L[role] : LIGHT_L[role],
c: 0,
h: swatch.oklch.h,
}
const token = {
...base,
c: clampRoleChroma(role, swatch.oklch.c),
}
可以把这个策略概括成:
Token = 主题亮度骨架 + 图片色相 + 受限制的图片色度
这也是为什么同一张照片生成的浅色和深色主题看起来属于同一个家族,却不会只是简单反相。
前景色不是纯黑白#
前景色先根据背景的相对亮度判断应该使用深色还是浅色骨架,阈值是 0.18。随后它会继承背景约 18% 的色度,并再次进行限制。
所以正文看起来仍然带一点封面的气质,但不会因为照片过于鲜艳而牺牲阅读性。
--destructive 是例外。错误和危险操作需要稳定的红色语义,因此它不跟随封面变化,而是为 light 和 dark 分别保留固定 OKLCH 值。
从色板生成背景纹理#
色板负责 UI,Canvas 负责背景。图片加载后,组件会在 Canvas 中进行逐像素处理。
第一步是用标准相对亮度权重计算灰度基准:
gray = 0.2126R + 0.7152G + 0.0722B
然后每个通道围绕灰度调整饱和度:
channel' = gray + (channel - gray) × saturate
接着把处理后的颜色向 Vibrant 主色混合,混合强度是 tint × alpha。最后三个 RGB 通道加入同一个随机噪声:
const noise = (Math.random() - 0.5) * grain * 90
相同噪声同时作用于 R、G、B,不会额外制造彩色噪点,更接近亮度颗粒。处理结果以质量 0.92 编码成 JPEG,作为最终背景。
尺寸策略和 CORS#
组件不会无条件把图片放大。它只会把过大的源图缩小,并把长边限制在 640–2560px 的安全范围内。
当前还有一个兼容旧接口的规则:tile-size 小于 640 时会被当作旧 tile 参数,实际使用 2048px 长边。因此 demo 中 128–384 的 slider 目前不会真正改变输出分辨率。这是一个应该在后续版本修正的接口债务。
远程图片会设置:
image.crossOrigin = 'anonymous'
服务器必须返回正确的 CORS header,否则浏览器虽然可能显示图片,却无法安全执行 getImageData()。这也是我在博客项目里优先使用同源代理或允许跨域读取的 R2 图片的原因。
Overlay 负责保护文字,不负责修复所有构图#
背景图和正文叠在一起时,单靠动态文字颜色仍然不够。图片局部可能过亮、过暗或对比度很复杂,因此 HueTexture 提供四种 overlay:
none:不添加保护层;gradient:从轻遮罩过渡到更强的 veil;uniform:整面统一遮罩;blur:半透明表面加backdrop-filter: blur(18px)。
自动透明度在浅色模式下是 0.38,深色模式下是 0.78。使用者也可以通过 overlay-opacity 显式覆盖。
当前 blur 分支是一个例外:它使用固定的浅色 0.22 或深色 0.34 背景 alpha,并不会完整消费 overlay-opacity。这不是理想的最终 API,但把这种差异写清楚,比用统一表格掩盖真实行为更重要。
theme=auto 的判断顺序#
自动主题会依次检查:
html或body的data-theme;html.dark或body.dark;prefers-color-scheme: dark。
组件监听根节点 class、data-theme 和系统主题变化。显式传入 theme="light" 或 theme="dark" 时,则不再创建这些 observer。
按压反馈不是鼠标光圈#
HueTexture 的 press="soft" 最初只是想让背景“有一点纸感”,但简单的 radial-gradient 光圈很快暴露出问题:它只是在原图上盖一层颜色,边缘容易出现膜感,也无法表现真正的形变。
现在的实现由两部分组成:低分辨率压力场,以及重新采样背景图片的渲染器。
压力场如何记录鼠标轨迹#
压力场 Canvas 的尺寸只有 CSS 表面的三分之一:
const FIELD_SCALE = 1 / 3
鼠标移动时,组件使用 getCoalescedEvents() 读取浏览器合并的高频指针点,在压力场中铺设宽而柔和的 stamp。brush 的衰减函数是:
(1 - position²)²
它在边缘同时收敛到零值和零斜率,因此压痕不会出现一圈明显硬边。
压痕中心不是瞬间贴住鼠标,而是使用临界阻尼弹簧追踪目标点。快速划过时,压力会向 0.55 的下限减轻;鼠标停下来后,纸面再慢慢沉下去。这种略微落后于指针的运动,比“光圈粘着鼠标”更接近有质量的材料。
点击会使用更深、更紧的 brush,同时产生三圈延迟 200ms 的涟漪。每圈持续约 1400ms,并逐渐向外扩散和衰减。
WebGL 只做位移,不重新染色#
WebGL fragment shader 读取压力场 alpha,并向下偏移背景纹理的采样坐标:
float pressure = texture2D(uField, vUv).a;
vec2 sampleUv =
(vUv - vec2(0.0, uSink * pressure)) *
uCoverScale + uCoverOffset;
vec3 color = texture2D(
uTexture,
clamp(sampleUv, 0.0, 1.0)
).rgb;
float alpha = smoothstep(0.0, 0.15, pressure);
gl_FragColor = vec4(color * alpha, alpha);
这里没有额外 tint,也没有在边缘混合一层新颜色。press Canvas 只绘制发生形变的区域,边缘通过压力值平滑归零,与底下 CSS cover 背景衔接。
为了避免压痕区域像另一张图片,press 采样的不是原始 Canvas,而是重新加载最终 JPEG Data URL。JPEG 编码会细微改变 grain;只有让背景和 press 使用完全相同的最终位图,压痕边界才不会出现一层不同质感的薄膜。
WebGL 不可用时仍然可以降级#
如果 WebGL context 创建失败,组件会退回 Canvas 2D:先绘制向下偏移的 cover 图片,再用压力场执行 destination-in alpha mask。
Canvas 2D 的形变感更柔软,也不如 shader 精确,但基础体验仍然存在。如果连 Canvas 2D 都不可用,press 会直接失效,色板、背景、overlay 和正文内容仍然正常。
触摸设备、非 fine pointer 和 prefers-reduced-motion: reduce 不会驱动按压输入;CSS 也会隐藏 press Canvas。
palettechange 是状态流,不是一次性 loaded 事件#
组件会在 host 上派发 palettechange:
texture.addEventListener('palettechange', (event) => {
const detail = (event as CustomEvent).detail
console.log(detail.palette)
console.log(detail.themes.light)
console.log(detail.themes.dark)
console.log(detail.mode)
console.log(detail.ready)
console.log(detail.error)
})
事件 detail 包含:
interface PaletteChangeDetail {
palette: ExtractedPalette
themes: {
light: Record<`--${string}`, string>
dark: Record<`--${string}`, string>
}
mode: 'light' | 'dark'
ready: boolean
error: string | null
}
它可能在 fallback 初始化、色板提取、纹理处理完成、主题切换或错误状态变化时多次触发。消费者应该把它当作状态流,而不是只监听一次的 load。
事件当前没有设置 bubbles: true 和 composed: true,所以最稳妥的做法是直接监听 hue-texture 元素本身,不要依赖 document 事件委托。
同时返回两套主题的价值#
host 只会把当前模式的 token 写成 inline CSS 变量,但事件会同时返回 light 和 dark 两套主题。
这让外部组件可以提前保存两套强调色。例如博客顶部导航和右侧目录不能简单把深色模式的 --primary 当成荧光笔颜色,因为语义 primary 在深色主题里本来就接近浅色前景。外层可以改用每套主题的 --ring,建立独立的装饰 token,而不破坏按钮和文字的语义色。
异步任务必须防止旧结果覆盖新图片#
在 demo 中快速切换图片时,旧图片的色板提取可能比新图片更晚完成。如果没有竞态保护,界面会出现“背景已经换了,颜色却突然跳回上一张”的问题。
组件分别维护 paletteJob 和 textureJob 递增编号:
const jobId = ++paletteJob
const image = await loadImage(nextSrc)
if (jobId !== paletteJob) return
每次输入变化都会让旧任务编号失效。旧 Promise 即使最终成功,也不能再写入当前状态。
这是一种很朴素但有效的浏览器端竞态控制,不需要为了单张图片处理引入复杂任务队列。
失败应该分层,而不是一起失败#
我希望 HueTexture 即使失去所有增强效果,也不能拖垮 slot 内的文章。因此不同阶段拥有不同降级路径。
| 失败位置 | 降级结果 |
|---|---|
| 图片加载失败 | fallback 色板,背景尝试使用原始 URL,正文继续显示 |
| 色板提取失败 | 使用 #798da8 fallback,仍继续处理图片 |
| Canvas 纹理失败 | 直接使用原始图片,色板仍然可用 |
| WebGL 失败 | press 退回 Canvas 2D |
| 所有 press renderer 失败 | 只关闭按压,背景和主题继续工作 |
组件还会写入:
data-ready="true|false"
data-theme="light|dark"
data-error="..."
外部界面可以据此显示 processing、ready 或降级提示。
这套设计背后的原则是:增强功能可以逐层失效,基础内容不能跟着失效。
在 Next.js 中接入#
由于自定义元素依赖浏览器环境,Next.js 中应在 Client Component 内动态导入:
'use client'
import { useEffect } from 'react'
export default function TexturePage() {
useEffect(() => {
void import('huetexture')
}, [])
return (
<hue-texture
src="/images/cover.jpg"
theme="auto"
overlay="gradient"
press="soft"
>
<main className="text-foreground">
<article className="bg-card text-card-foreground">
Content
</article>
</main>
</hue-texture>
)
}
当前 npm 包还没有发布 .d.ts,所以 TypeScript 项目需要补一份 JSX intrinsic element 声明:
declare namespace React {
namespace JSX {
interface IntrinsicElements {
'hue-texture': React.HTMLAttributes<HTMLElement> & {
src?: string
theme?: 'auto' | 'light' | 'dark'
overlay?: 'none' | 'gradient' | 'uniform' | 'blur'
press?: 'soft' | 'none'
tint?: number | string
saturate?: number | string
grain?: number | string
'overlay-opacity'?: number | string
'tile-size'?: number | string
}
}
}
}
这也是后续发布需要补齐的内容:库不应该让每个 React 使用者都重复维护同一份类型声明。
Demo 为什么分成配置台和文章页#
demo-dist 不是单纯的宣传首页,它承担两种测试任务。
配置调试页用于快速切换内置图片或上传本地图片,并实时调整 tint、saturate、grain、overlay、theme 和尺寸参数。旁边会显示六类提取色板以及 ready/error 状态。
文章预览页则把同一组参数放进完整阅读场景。很多问题在小卡片里看不出来:背景覆盖范围、长文本对比度、深浅主题切换、pointer 离开边界和大尺寸 press Canvas,都只有进入真实页面结构后才会暴露。
构建命令也分成两条:
# 输出 npm library 到 dist/
npm run build
# 输出静态 demo 到 demo-dist/
npm run build:demo
library build 使用 Vite 的 ES library mode,只输出 hue-texture.js 和 source map;demo build 则输出普通静态站点资源。
当前实现的性能边界#
这部分我不想用“GPU 加速”四个字一笔带过。HueTexture 的确使用 WebGL,但整个流水线并不都在 GPU 上。
当前明确存在这些成本:
- node-vibrant 和 Canvas 像素循环都运行在浏览器主线程;
- 还没有使用 Web Worker 或 OffscreenCanvas;
- 没有按照
src + options缓存处理结果; processTexture()同时执行toBlob()和toDataURL(),发生两次 JPEG 编码;- 主组件目前只消费 Data URL,Blob 还没有发挥作用;
- Data URL 会带来额外字符串和内存占用;
- 最大处理图片可以达到 2560px 长边;
- press 活跃帧会把压力场 Canvas 重新上传到 WebGL texture;
bluroverlay 覆盖大面积区域时会增加合成成本;- coarse pointer 虽然不会驱动 press,但 renderer 的创建时机还可以进一步延迟。
Press 路径也设置了保护边界:
- backing store 最多 900 万像素;
- DPR 限制在 1–2,并继续受像素预算约束;
- 压力场只使用三分之一分辨率;
- trail stamps 最多 512 个;
- ripple 最多 8 组;
- 图片超过设备
MAX_TEXTURE_SIZE时先缩小; - 静止压痕稳定后停止持续 RAF;
- destroy 时释放 observer、RAF、program、buffer 和 texture。
这些限制让 0.1.x 可以实际使用,但它们不等于优化已经结束。
下一阶段最值得做的优化#
我的优先级大致是:
- 把色板和纹理处理移动到 Worker / OffscreenCanvas;
- 用
src + tint + saturate + grain + size建立缓存键; - 避免同时进行 Blob 和 Data URL 两次编码;
- 给包补充
.d.ts和更完整的事件类型; - 把历史
tile-size迁移成更准确的尺寸属性; - 在 coarse pointer 或 reduced motion 设备上延迟创建 renderer;
- 减少活跃帧中压力场纹理的完整上传;
- 为背景几何、press 可见性和 renderer 状态提供更明确的公共 API;
- 增加自动化测试和可重复的性能基准。
这个组件教会我的几件事#
第一,动态主题不能只追求“颜色像原图”。真正可靠的方案必须保留一套明暗骨架,再让图片控制色相和有限色度。
第二,Web Component 的价值不只是跨框架。它迫使组件把边界讲清楚:Shadow DOM 管内部渲染,host 负责状态和 token,slot 负责业务内容。
第三,WebGL 最适合解决明确的渲染问题。HueTexture 用它做纯位移,而不是把所有图片处理都搬进 shader;这样 Canvas、CSS 和 WebGL 各自承担最合适的部分。
第四,降级不是在最后补一个 catch。图片加载、色板、纹理和 press 从设计开始就是四条可以独立失败的链路。
最后,demo 必须覆盖真实使用场景。一个 320px 的展示卡片证明不了组件能够支撑全屏文章、长页面滚动、动态主题和复杂浏览器合成。
HueTexture 目前仍有不少接口债务和性能优化空间,但核心方向已经明确:让一张图片不只是页面背景,而是成为主题、材质和交互共同使用的视觉源头。