uselayouts
componentliquid-indexsource: ok · refreshed
Liquid Index
A feature list whose hover badge is a WebGL liquid gradient that tracks the row.
npx shadcn add "https://uselayouts.com/r/liquid-index.json"free
Where it goes
feature-grid
Visual style
gradient
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files2 · source cached 10/8/2026, 7:40:34 AM
registry/default/example/liquid-index.tsx
"use client"
import { useEffect, useLayoutEffect, useRef, useState } from "react"
import {
AnimatePresence,
motion,
useReducedMotion,
} from "motion/react"
import {
BookOpen,
Camera,
Droplets,
Flame,
SwatchBook,
type LucideIcon,
} from "lucide-react"
import { LiquidGradientCanvas } from "./liquid-index-canvas"
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.36 }
const COLOR_EASE = [0.25, 0.1, 0.25, 1] as const
const IMG = "auto=format&fit=crop&w=800&q=80"
type Item = {
title: string
description: string
image: string
icon: LucideIcon
seed: number
colors: [number, number, number][]
}
const ITEMS: Item[] = [
{
title: "Marbling",
description: "Wet color that settles into the grain instead of sitting on top.",
image: `https://images.unsplash.com/photo-1541701494587-cb58502866ab?${IMG}`,
icon: Droplets,
seed: 648,
colors: [
[0, 0, 26],
[41, 98, 255],
[64, 188, 255],
],
},
{
title: "Darkroom",
description: "Trays, safelight, and a print that only appears in the last minute.",
image: `https://images.unsplash.com/photo-1557672172-298e090bd0f1?${IMG}`,
icon: Camera,
seed: 732,
colors: [
[22, 11, 0],
[255, 138, 0],
[255, 193, 79],
],
},
{
title: "Kiln",
description: "Clay that is unfinished until it comes back from the fire.",
image: `https://images.unsplash.com/photo-1574169208507-84376144848b?${IMG}`,
icon: Flame,
seed: 516,
colors: [
[18, 10, 36],
[124, 58, 237],
[255, 139, 209],
],
},
{
title: "Binding",
description: "Signatures sewn so the book can open flat on a desk.",
image: `https://images.unsplash.com/photo-1550684848-fac1c5b4e853?${IMG}`,
icon: BookOpen,
seed: 884,
colors: [
[6, 19, 13],
[0, 200, 83],
[182, 255, 106],
],
},
{
title: "Proofing",
description: "One last pass on the sheet before the press run starts.",
image: `https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?${IMG}`,
icon: SwatchBook,
seed: 291,
colors: [
[11, 16, 32],
[51, 65, 85],
[148, 163, 184],
],
},
]
function useIsPhone() {
const [isPhone, setIsPhone] = useState(false)
useEffect(() => {
const media = window.matchMedia("(max-width: 809px)")
const update = () => setIsPhone(media.matches)
update()
media.addEventListener("change", update)
return () => media.removeEventListener("change", update)
}, [])
return isPhone
}
export default function LiquidIndex() {
const isPhone = useIsPhone()
const reduceMotion = useReducedMotion()
const [active, setActive] = useState<number | null>(null)
const [offsetY, setOffsetY] = useState(0)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const current = active == null ? ITEMS[0] : ITEMS[active]
const Icon = current.icon
const transition = reduceMotion ? { duration: 0 } : SPRING
useLayoutEffect(() => {
if (isPhone || active == null) {
setOffsetY(0)
return
}
const first = itemRefs.current[0]
const item = itemRefs.current[active]
if (!first || !item) return
setOffsetY(item.offsetTop - first.offsetTop)
}, [active, isPhone])
return (
<div
className={
isPhone
? "flex w-full max-w-[931px] flex-col items-start gap-[27px]"
: "flex w-full max-w-[931px] flex-row items-stretch gap-12"
}
>
<div
className={
isPhone
? "relative flex w-[120px] shrink-0 flex-col items-center"
: "relative flex w-[220px] shrink-0 flex-col items-center"
}
>
<motion.div
className={
isPhone
? "relative flex aspect-square w-full items-center justify-center overflow-hidden rounded-[24px] bg-muted opacity-0"
: "relative flex aspect-square w-full items-center justify-center overflow-hidden rounded-[32px] bg-muted opacity-0"
}
initial={false}
animate={{
opacity: active == null ? 0 : 1,
y: isPhone || active == null ? 0 : offsetY,
}}
transition={transition}
>
<AnimatePresence initial={false} mode="sync">
<motion.img
key={current.image}
src={current.image}
alt=""
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: reduceMotion ? 0 : 0.18,
ease: COLOR_EASE,
}}
className="absolute inset-0 size-full object-cover"
draggable={false}
/>
</AnimatePresence>
<div className="absolute inset-0 mix-blend-soft-light">
<LiquidGradientCanvas
colors={current.colors}
seed={current.seed}
paused={!!reduceMotion}
/>
</div>
<div
className={
isPhone
? "relative z-[2] flex size-9 items-center justify-center text-white"
: "relative z-[2] flex size-14 items-center justify-center text-white"
}
>
<AnimatePresence initial={false} mode="sync">
<motion.span
key={current.title}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: reduceMotion ? 0 : 0.18,
ease: COLOR_EASE,
}}
className="absolute inset-0 flex items-center justify-center"
>
<Icon
className={isPhone ? "size-7" : "size-10"}
fill="currentColor"
strokeWidth={0}
aria-hidden
/>
</motion.span>
</AnimatePresence>
</div>
</motion.div>
</div>
<div
className={
isPhone
? "flex min-w-0 flex-1 flex-col items-start gap-[19px] py-0"
: "flex min-w-0 flex-1 flex-col items-start gap-[25px] py-[81px]"
}
onMouseLeave={() => {
if (!isPhone) setActive(null)
}}
>
<div className="flex w-full flex-col items-start">
{ITEMS.map((item, index) => {
const isActive = active === index
const inactive = active != null && !isActive
return (
<motion.button
key={item.title}
ref={(node) => {
itemRefs.current[index] = node
}}
type="button"
onMouseEnter={() => {
if (!isPhone) setActive(index)
}}
onClick={() => {
if (isPhone) setActive(isActive ? null : index)
}}
className={
isPhone
? "flex w-full cursor-pointer flex-col items-start gap-[5px] py-[5px] text-left"
: "flex w-auto cursor-pointer flex-col items-start gap-0 py-[7px] text-left"
}
animate={{ paddingLeft: isActive ? 8 : 0 }}
transition={transition}
>
<motion.span
className={
isPhone
? "w-full select-none text-[26px] font-semibold leading-[1.14em] tracking-[-0.03em] whitespace-pre-wrap"
: "w-auto select-none text-[44px] font-semibold leading-[1.1em] tracking-[-0.04em] whitespace-nowrap"
}
animate={{
color: inactive
? "var(--muted-foreground)"
: "var(--foreground)",
}}
transition={{
duration: reduceMotion ? 0 : 0.18,
ease: COLOR_EASE,
}}
>
{item.title}
</motion.span>
<AnimatePresence initial={false}>
{isPhone && isActive ? (
<motion.p
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={transition}
className="max-w-[520px] overflow-hidden text-[17px] font-medium leading-[1.45em] tracking-[-0.018em] text-pretty text-muted-foreground"
>
{item.description}
</motion.p>
) : null}
</AnimatePresence>
</motion.button>
)
})}
</div>
<div
className={
isPhone
? "hidden"
: "min-h-[62px] w-full max-w-[520px]"
}
>
<AnimatePresence mode="wait" initial={false}>
{active != null ? (
<motion.p
key={current.title}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={transition}
className="text-[22px] font-medium leading-[1.42em] tracking-[-0.025em] text-pretty text-muted-foreground"
>
{current.description}
</motion.p>
) : null}
</AnimatePresence>
</div>
</div>
</div>
)
}
registry/default/example/liquid-index-canvas.tsx
"use client"
import { useEffect, useRef } from "react"
const VERTEX = `#version 300 es
in vec2 a_position;
out vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}
`
const FRAGMENT = `#version 300 es
precision highp float;
precision highp int;
in vec2 v_uv;
out vec4 fragColor;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_pixelRatio;
uniform vec4 u_colors[8];
uniform int u_colors_length;
uniform float u_seed;
uniform float u_speed;
uniform float u_loop;
uniform float u_scale;
uniform float u_turbAmp;
uniform float u_turbFreq;
uniform float u_turbIter;
uniform float u_waveFreq;
uniform float u_distBias;
uniform float u_jellify;
uniform float u_ditherMode;
uniform float u_dither;
uniform float u_exposure;
uniform float u_contrast;
uniform float u_saturation;
const float GOLDEN_ANGLE = 2.3999632;
const float TAU = 6.28318530;
uvec3 hash3(uvec3 v) {
v = v * 1664525u + 1013904223u;
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
v ^= v >> 16u;
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
return v;
}
vec3 seedRandom(float seedVal) {
uvec3 s = uvec3(
floatBitsToUint(seedVal),
floatBitsToUint(seedVal * 1.5 + 7.31),
floatBitsToUint(seedVal * 2.7 + 13.37)
);
s = hash3(s);
return vec3(s) / float(0xFFFFFFFFu);
}
vec3 toLinear(vec3 c) {
return pow(c, vec3(2.2));
}
vec3 toSrgb(vec3 c) {
return pow(clamp(c, 0.0, 1.0), vec3(0.4545));
}
vec3 linearToOklab(vec3 c) {
float l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;
float m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;
float s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;
l = pow(max(l, 0.0), 1.0/3.0);
m = pow(max(m, 0.0), 1.0/3.0);
s = pow(max(s, 0.0), 1.0/3.0);
return vec3(
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
);
}
vec3 oklabToLinear(vec3 c) {
float l = c.x + 0.3963377774 * c.y + 0.2158037573 * c.z;
float m = c.x - 0.1055613458 * c.y - 0.0638541728 * c.z;
float s = c.x - 0.0894841775 * c.y - 1.2914855480 * c.z;
l = l * l * l;
m = m * m * m;
s = s * s * s;
return vec3(
+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
);
}
vec3 oklabToLch(vec3 lab) {
return vec3(lab.x, length(lab.yz), atan(lab.z, lab.y));
}
vec3 lchToOklab(vec3 lch) {
return vec3(lch.x, lch.y * cos(lch.z), lch.y * sin(lch.z));
}
vec3 mixLch(vec3 lab0, vec3 lab1, float t) {
vec3 lch0 = oklabToLch(lab0);
vec3 lch1 = oklabToLch(lab1);
if (lch0.y < 0.05) lch0.z = lch1.z;
if (lch1.y < 0.05) lch1.z = lch0.z;
float dh = lch1.z - lch0.z;
if (dh > 3.14159265) dh -= 6.28318530;
if (dh < -3.14159265) dh += 6.28318530;
return lchToOklab(vec3(
mix(lch0.x, lch1.x, t),
mix(lch0.y, lch1.y, t),
lch0.z + dh * t
));
}
vec3 getColor(int idx) {
if (u_colors_length < 1) return vec3(0.0);
int safeIdx = clamp(idx, 0, u_colors_length - 1);
return u_colors[safeIdx].rgb;
}
vec3 paletteN(float t, int count) {
if (count < 1) return vec3(0.0);
if (count < 2) return toLinear(getColor(0));
float segmentSize = 1.0 / float(count - 1);
t = clamp(t, 0.0, 1.0);
int idx = min(int(floor(t / segmentSize)), count - 2);
float localT = clamp((t - float(idx) * segmentSize) / segmentSize, 0.0, 1.0);
vec3 lab0 = linearToOklab(toLinear(getColor(idx)));
vec3 lab1 = linearToOklab(toLinear(getColor(idx + 1)));
return oklabToLinear(mixLch(lab0, lab1, localT));
}
float IGN(vec2 uv) {
return fract(52.9829189 * fract(dot(uv, vec2(0.06711056, 0.00583715))));
}
float quickNoise(vec2 I) {
return fract(sin(dot(I, vec2(12.9898, 78.233))) * 43758.5453);
}
float getDither(vec2 I, float mode) {
if (mode < 0.5) return 0.5;
if (mode < 1.5) return IGN(I);
return quickNoise(I);
}
vec3 softGamutMap(vec3 linearRgb) {
float maxC = max(linearRgb.r, max(linearRgb.g, linearRgb.b));
float minC = min(linearRgb.r, min(linearRgb.g, linearRgb.b));
if (minC >= 0.0 && maxC <= 1.0) return linearRgb;
vec3 lab = linearToOklab(max(linearRgb, 0.0));
float L = clamp(lab.x, 0.0, 1.0);
float C = length(lab.yz);
float h = atan(lab.z, lab.y);
float maxChroma = 0.4 * (1.0 - pow(abs(2.0 * L - 1.0), 2.0));
if (C > maxChroma * 0.7) {
float knee = maxChroma * 0.7;
C = knee + (maxChroma - knee) * tanh((C - knee) / (maxChroma - knee + 0.001));
}
return clamp(oklabToLinear(vec3(L, C * cos(h), C * sin(h))), 0.0, 1.0);
}
vec3 applyContrastSaturation(vec3 linearRgb, float contrast, float saturation) {
vec3 lab = linearToOklab(linearRgb);
float C = length(lab.yz);
float h = atan(lab.z, lab.y);
lab.x = clamp((lab.x - 0.5) * contrast + 0.5, 0.0, 1.0);
C *= saturation;
lab.y = C * cos(h);
lab.z = C * sin(h);
return oklabToLinear(lab);
}
void main() {
vec2 fragCoord = v_uv * u_resolution;
vec2 r = u_resolution;
vec2 p = (fragCoord * 2.0 - r) / r.y;
int colorCount = u_colors_length;
if (colorCount < 1) {
fragColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
float t = u_time * 0.3;
float looping = step(0.5, u_loop);
float phase = TAU * u_time / max(u_loop, 0.01);
float radius = u_loop * u_speed * 0.3 / TAU;
float tA = sin(phase) * radius;
float tB = (1.0 - cos(phase)) * radius;
vec3 seedOffset = seedRandom(u_seed);
vec3 seedOffset2 = seedRandom(u_seed + 100.0);
float seedAngle = u_seed * GOLDEN_ANGLE;
vec2 seedPhase = (seedOffset2.xy - 0.5) * TAU;
float cs = cos(seedAngle);
float sn = sin(seedAngle);
p = mat2(cs, -sn, sn, cs) * p;
float dither = getDither(floor(fragCoord / u_pixelRatio), u_ditherMode);
float totalVal = 0.0;
float totalWeight = 0.0;
int turbIter = int(u_turbIter);
float freq = 1.0 / max(u_turbFreq, 0.01);
for (float i = 0.0; i < 4.0; i++) {
float eph = i / 4.0;
vec2 q = p * u_scale;
float sq = eph * eph;
if (u_jellify > 0.5) {
q.yx *= mix(1.0, 0.5, 1.0 - exp(-sq));
}
float a = seedPhase.x;
float d = seedPhase.y;
for (int j = 2; j < 13; j++) {
if (j >= turbIter) break;
float fj = float(j);
float t1 = mix(t * u_speed, tA, looping);
float t2 = mix(t * u_speed, tB, looping);
q += u_turbAmp * sin(q.yx / freq * fj + t1 + vec2(a, d) + seedOffset.xy * fj) / fj;
a += cos(fj + d * 1.2 + q.x * 2.0 - t1 + seedOffset2.z + t2 * 0.3 * looping);
d += sin(fj * q.y + a + seedOffset.z + t1 + seedOffset2.y + t2 * 0.3 * looping);
}
float v = 0.5 + 0.5 * sin(length(q.yx + vec2(a, d) * 0.2) * u_waveFreq + i * i + seedOffset.x);
float weight = smoothstep(0.0, 0.5, eph) * smoothstep(1.0, 0.5, eph);
totalVal += v * weight;
totalWeight += weight;
}
float val = totalVal / totalWeight;
val = clamp((val - 0.3) / 0.4, 0.0, 1.0);
val = pow(val, exp(-u_distBias));
val = clamp(val + (dither - 0.5) * u_dither, 0.0, 1.0);
vec3 col = paletteN(val, colorCount);
col *= u_exposure;
col = applyContrastSaturation(col, u_contrast, u_saturation);
col = softGamutMap(col);
col = toSrgb(col);
fragColor = vec4(col, 1.0);
}
`
export type ShaderConfig = {
colors: [number, number, number][]
seed: number
speed?: number
paused?: boolean
}
function compile(
gl: WebGL2RenderingContext,
type: number,
source: string
) {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
export function LiquidGradientCanvas({
colors,
seed,
speed = 0.45,
paused = false,
}: ShaderConfig) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const configRef = useRef({ colors, seed, speed, paused })
configRef.current = { colors, seed, speed, paused }
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const gl = canvas.getContext("webgl2", {
alpha: false,
antialias: false,
premultipliedAlpha: false,
})
if (!gl) return
const vs = compile(gl, gl.VERTEX_SHADER, VERTEX)
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)
if (!vs || !fs) return
const program = gl.createProgram()
if (!program) return
gl.attachShader(program, vs)
gl.attachShader(program, fs)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
return
}
const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 3, -1, -1, 3]),
gl.STATIC_DRAW
)
const loc = gl.getAttribLocation(program, "a_position")
gl.enableVertexAttribArray(loc)
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)
const uniforms = {
resolution: gl.getUniformLocation(program, "u_resolution"),
time: gl.getUniformLocation(program, "u_time"),
pixelRatio: gl.getUniformLocation(program, "u_pixelRatio"),
colors: gl.getUniformLocation(program, "u_colors"),
colorsLength: gl.getUniformLocation(program, "u_colors_length"),
seed: gl.getUniformLocation(program, "u_seed"),
speed: gl.getUniformLocation(program, "u_speed"),
loop: gl.getUniformLocation(program, "u_loop"),
scale: gl.getUniformLocation(program, "u_scale"),
turbAmp: gl.getUniformLocation(program, "u_turbAmp"),
turbFreq: gl.getUniformLocation(program, "u_turbFreq"),
turbIter: gl.getUniformLocation(program, "u_turbIter"),
waveFreq: gl.getUniformLocation(program, "u_waveFreq"),
distBias: gl.getUniformLocation(program, "u_distBias"),
jellify: gl.getUniformLocation(program, "u_jellify"),
ditherMode: gl.getUniformLocation(program, "u_ditherMode"),
dither: gl.getUniformLocation(program, "u_dither"),
exposure: gl.getUniformLocation(program, "u_exposure"),
contrast: gl.getUniformLocation(program, "u_contrast"),
saturation: gl.getUniformLocation(program, "u_saturation"),
}
let raf = 0
let start = performance.now()
let pausedAt = 0
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const width = canvas.clientWidth
const height = canvas.clientHeight
const w = Math.max(1, Math.round(width * dpr))
const h = Math.max(1, Math.round(height * dpr))
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w
canvas.height = h
}
gl.viewport(0, 0, w, h)
return { w, h, dpr }
}
const draw = (now: number) => {
const cfg = configRef.current
if (cfg.paused) {
pausedAt = now - start
}
const elapsed = (cfg.paused ? pausedAt : now - start) / 1000
const { w, h, dpr } = resize()
gl.useProgram(program)
gl.uniform2f(uniforms.resolution, w, h)
gl.uniform1f(uniforms.time, elapsed)
gl.uniform1f(uniforms.pixelRatio, dpr)
const packed = new Float32Array(32)
cfg.colors.forEach((color, i) => {
packed[i * 4] = color[0] / 255
packed[i * 4 + 1] = color[1] / 255
packed[i * 4 + 2] = color[2] / 255
packed[i * 4 + 3] = 1
})
gl.uniform4fv(uniforms.colors, packed)
gl.uniform1i(uniforms.colorsLength, cfg.colors.length)
gl.uniform1f(uniforms.seed, cfg.seed)
gl.uniform1f(uniforms.speed, cfg.speed)
gl.uniform1f(uniforms.loop, 0)
gl.uniform1f(uniforms.scale, 0.42)
gl.uniform1f(uniforms.turbAmp, 0.55)
gl.uniform1f(uniforms.turbFreq, 0.1)
gl.uniform1f(uniforms.turbIter, 7)
gl.uniform1f(uniforms.waveFreq, 3.6)
gl.uniform1f(uniforms.distBias, 0)
gl.uniform1f(uniforms.jellify, 0)
gl.uniform1f(uniforms.ditherMode, 0)
gl.uniform1f(uniforms.dither, 0.05)
gl.uniform1f(uniforms.exposure, 1.1)
gl.uniform1f(uniforms.contrast, 1.1)
gl.uniform1f(uniforms.saturation, 1)
gl.drawArrays(gl.TRIANGLES, 0, 3)
raf = requestAnimationFrame(draw)
}
raf = requestAnimationFrame(draw)
return () => {
cancelAnimationFrame(raf)
gl.deleteProgram(program)
gl.deleteShader(vs)
gl.deleteShader(fs)
gl.deleteBuffer(buffer)
}
}, [])
return (
<canvas
ref={canvasRef}
className="block h-full w-full"
draggable={false}
/>
)
}