# Feature 28 - Bento Grid with Animated Waves

> A bento-style feature grid highlighting service benefits with icon cards, capped off by an animated gradient wave card for the standout highlight.

A shadcn/ui block from the [@shadcn-space](https://shadcnspace.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcn-space/feature-28).

- Page, with a live preview: https://shadcn.exchange/r/shadcn-space/feature-28
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcn-space/feature-28-light-382b8f83c0eacc3d.png
- Registry item: https://shadcnspace.com/r/feature-28.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcnspace.com/r/feature-28.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`, `ogl`
- Registry items: `badge`

## Files

### `src/components/shadcn-space/blocks/feature-28/page.tsx`

```tsx
import Feature28 from "@/components/shadcn-space/blocks/feature-28";

export default function page() {
    return (
        <Feature28 />
    )
}
```

### `src/components/shadcn-space/blocks/feature-28/index.tsx`

```tsx
"use client";

import { Badge } from "@/components/ui/badge";
import { motion } from "motion/react";
import {
  Headphones,
  Paperclip,
  Palette,
  ShieldCheck,
  Trophy,
  Wand2,
  Zap,
  Flower,
  type LucideIcon,
} from "lucide-react";
import GradientWaves from "@/components/shadcn-space/animations/GradientWaves";

const FADE_UP_ANIMATION_VARIANTS = {
  hidden: { opacity: 0, y: 24 },
  show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.21, 0.47, 0.32, 0.98] } },
};

const STAGGER_ANIMATION_VARIANTS = {
  hidden: { opacity: 0 },
  show: { opacity: 1, transition: { staggerChildren: 0.1, delayChildren: 0.1 } },
};

const CARD_ANIMATION_VARIANTS = {
  hidden: { opacity: 0, y: 24, scale: 0.98 },
  show: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.6, ease: [0.21, 0.47, 0.32, 0.98] } },
};

type FeatureCard = {
  icon: LucideIcon;
  title: string;
  description: string;
  span: string;
};

const topRow: FeatureCard[] = [
  {
    icon: Zap,
    title: "Affordability",
    description: "Access high-quality design services at a fraction of traditional costs.",
    span: "lg:col-span-1",
  },
  {
    icon: Wand2,
    title: "Consistency",
    description: "Ensure a consistent brand identity with regular design output.",
    span: "lg:col-span-1",
  },
  {
    icon: Paperclip,
    title: "Scalability",
    description: "Scalable systems built to support growing products and businesses.",
    span: "lg:col-span-1",
  },
];

const bottomRow: FeatureCard[] = [
  {
    icon: Trophy,
    title: "Flexibility",
    description: "Adapt the service to cover a wide range of design tasks as needed.",
    span: "lg:col-span-1",
  },
  {
    icon: Flower,
    title: "Diversity",
    description: "Access to a variety of styles and expertise from a pool of creative professionals and people.",
    span: "lg:col-span-2",
  },
  {
    icon: Headphones,
    title: "Support",
    description: "Enjoy dedicated customer service and revisions to perfect your designs.",
    span: "lg:col-span-1",
  },
  {
    icon: ShieldCheck,
    title: "Convenience",
    description: "Streamline the design process with a simple workflow and process.",
    span: "lg:col-span-1",
  },
];

const Feature28 = () => {
  return (
    <section className="w-full scroll-mt-20 bg-muted py-12 md:py-20 lg:py-24">
      <div className="mx-auto max-w-7xl px-4 md:px-8 xl:px-16">
        <motion.div
          initial="hidden"
          whileInView="show"
          viewport={{ once: true, amount: 0.4 }}
          variants={STAGGER_ANIMATION_VARIANTS}
          className="mx-auto mb-12 flex max-w-2xl flex-col items-center gap-4 text-center md:mb-16"
        >
          <motion.div variants={FADE_UP_ANIMATION_VARIANTS}>
            <Badge className="h-auto rounded-lg border-border bg-background px-3 py-1 text-sm font-normal text-foreground">
              Why Choose Us
            </Badge>
          </motion.div>
          <motion.h2
            variants={FADE_UP_ANIMATION_VARIANTS}
            className="text-3xl leading-tight font-medium tracking-tight text-foreground sm:text-4xl lg:text-5xl"
          >
            Features to Boost Productivity
          </motion.h2>
          <motion.p
            variants={FADE_UP_ANIMATION_VARIANTS}
            className="text-base text-muted-foreground md:text-lg"
          >
            Explore powerful tools built to simplify your workflow and help you get more done, faster and smarter.
          </motion.p>
        </motion.div>

        <motion.div
          initial="hidden"
          whileInView="show"
          viewport={{ once: true, amount: 0.1 }}
          variants={STAGGER_ANIMATION_VARIANTS}
          className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5"
        >
          {topRow.map((card) => (
            <motion.div
              key={card.title}
              variants={CARD_ANIMATION_VARIANTS}
              className={`flex min-h-56 sm:min-h-64 flex-col justify-between gap-8 rounded-2xl bg-card p-6 transition-colors ${card.span}`}
            >
              <div className="flex size-11 items-center justify-center rounded-xl bg-muted text-foreground">
                <card.icon className="size-5" />
              </div>
              <div className="flex flex-col gap-1.5">
                <h3 className="text-lg font-medium tracking-tight text-foreground">{card.title}</h3>
                <p className="text-sm text-muted-foreground">{card.description}</p>
              </div>
            </motion.div>
          ))}

          <motion.div
            variants={CARD_ANIMATION_VARIANTS}
            className="relative flex min-h-56 sm:min-h-64 flex-col justify-between gap-8 overflow-hidden rounded-2xl bg-neutral-950 p-6 lg:col-span-2"
          >
            <div className="absolute inset-0">
              <GradientWaves
                horizonColor="#ffffff"
                waveColor="#3a3a3a"
                crestColor="#FFFFFF"
                speed={0.4}
                amplitude={2.5}
                waveScale={0.6}
                waveRatio={0.9}
                swell={35}
                turbulence={20}
                tilt={1.11}
                zoom={1.0}
                height={5.5}
                fogDepth={15}
                detail="medium"
                brightness={1.0}
                opacity={1.0}
                mouseInteraction={true}
                parallaxStrength={0.5}
                grain={true}
                grainIntensity={0.05}
              />
            </div>
            <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent" />

            <div className="relative z-10 flex size-11 items-center justify-center rounded-xl bg-white/15 text-white backdrop-blur-sm">
              <Palette className="size-5" />
            </div>
            <div className="relative z-10 flex flex-col gap-1.5">
              <h3 className="text-lg font-medium tracking-tight text-white">Speed</h3>
              <p className="text-sm text-white/70">
                Get quicker turnarounds on design projects without sacrificing quality at a way better price on your wallet.
              </p>
            </div>
          </motion.div>

          {bottomRow.map((card) => (
            <motion.div
              key={card.title}
              variants={CARD_ANIMATION_VARIANTS}
              className={`flex min-h-56 sm:min-h-64 flex-col justify-between gap-8 rounded-2xl bg-card p-6 transition-colors ${card.span}`}
            >
              <div className="flex size-11 items-center justify-center rounded-xl bg-muted text-foreground">
                <card.icon className="size-5" />
              </div>
              <div className="flex flex-col gap-1.5">
                <h3 className="text-lg font-medium tracking-tight text-foreground">{card.title}</h3>
                <p className="text-sm text-muted-foreground">{card.description}</p>
              </div>
            </motion.div>
          ))}
        </motion.div>
      </div>
    </section>
  );
};

export default Feature28;
```

### `src/components/shadcn-space/animations/GradientWaves.tsx`

```tsx
"use client";

import { useEffect, useRef } from "react";
import { Renderer, Program, Mesh, Triangle } from "ogl";

type DetailLevel = "low" | "medium" | "high";

interface GradientWavesProps {
  className?: string;
  horizonColor?: string;
  waveColor?: string;
  crestColor?: string;
  speed?: number;
  amplitude?: number;
  waveScale?: number;
  waveRatio?: number;
  swell?: number;
  turbulence?: number;
  tilt?: number;
  zoom?: number;
  height?: number;
  fogDepth?: number;
  detail?: DetailLevel;
  brightness?: number;
  opacity?: number;
  mouseInteraction?: boolean;
  parallaxStrength?: number;
  grain?: boolean;
  grainIntensity?: number;
}

const hexToRgb = (hex: string): [number, number, number] => {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  if (!result) return [0, 0, 0];
  return [
    parseInt(result[1], 16) / 255,
    parseInt(result[2], 16) / 255,
    parseInt(result[3], 16) / 255,
  ];
};

const detailToSteps = (detail: DetailLevel) => {
  if (detail === "low") return 60;
  if (detail === "high") return 110;
  return 90;
};

const VERTEX = `#version 300 es
in vec2 position;
void main() {
  gl_Position = vec4(position, 0.0, 1.0);
}
`;

const FRAGMENT = `#version 300 es
precision highp float;

uniform vec2 iResolution;
uniform float iTime;
uniform float uSpeed;
uniform float uAmplitude;
uniform float uWaveScale;
uniform float uWaveRatio;
uniform float uSwell;
uniform float uTurbulence;
uniform float uTilt;
uniform float uZoom;
uniform float uHeight;
uniform float uFogDepth;
uniform float uSteps;
uniform float uBrightness;
uniform float uOpacity;
uniform float uGrain;
uniform float uGrainIntensity;
uniform vec2 uMouse;
uniform float uParallax;
uniform bool uEnableMouse;
uniform vec3 uHorizonColor;
uniform vec3 uWaveColor;
uniform vec3 uCrestColor;

out vec4 fragColor;

const float MAX_DIST = 2000.0;

float hash21(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

float plasma(vec3 r, vec2 freq, vec4 tc) {
  float mx = r.x + tc.x;
  mx += uSwell * sin((r.y + mx) / 20.0 + tc.y);
  float my = r.y - tc.z;
  my += uTurbulence * cos(r.x / 23.0 + tc.w);
  return r.z - (sin(mx * freq.x) * uAmplitude + sin(my * freq.y) * uAmplitude + uHeight);
}

float raymarch(vec3 pos, vec3 dir, vec2 freq, vec4 tc) {
  float dist = 0.0;
  for (int i = 0; i < 128; i++) {
    if (float(i) >= uSteps) break;
    float dscene = plasma(pos + dist * dir, freq, tc);
    if (abs(dscene) < 0.1) break;
    dist += 0.9 * dscene;
    if (!(abs(dist) < MAX_DIST)) return MAX_DIST;
  }
  return dist;
}

void main() {
  float T = iTime * uSpeed;
  vec2 freq = vec2(uWaveScale / 7.0, (uWaveScale * uWaveRatio) / 3.0);
  vec4 tc = vec4(T / 0.130, T / 0.810, T / 0.200, T / 0.710);
  float c, s;
  float vfov = (3.14159 / 2.3) / max(uZoom, 0.05);
  vec3 cam = vec3(0.0, 0.0, 30.0);
  vec2 uv = (gl_FragCoord.xy / iResolution.xy) - 0.5;
  uv.x *= iResolution.x / iResolution.y;
  uv.y *= -1.0;
  vec3 dir = vec3(0.0, 0.0, -1.0);
  float ulen = length(uv);
  float xrot = vfov * ulen;
  c = cos(xrot); s = sin(xrot);
  dir = mat3(1.0, 0.0, 0.0, 0.0, c, -s, 0.0, s, c) * dir;
  vec2 nuv = ulen > 1e-5 ? uv / ulen : vec2(1.0, 0.0);
  c = nuv.x; s = nuv.y;
  dir = mat3(c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0) * dir;
  c = cos(uTilt); s = sin(uTilt);
  dir = mat3(c, 0.0, s, 0.0, 1.0, 0.0, -s, 0.0, c) * dir;
  if (uEnableMouse) {
    float yaw = (uMouse.x - 0.5) * uParallax * 0.4;
    float pitch = (uMouse.y - 0.5) * uParallax * 0.4;
    c = cos(yaw); s = sin(yaw);
    dir = mat3(c, 0.0, s, 0.0, 1.0, 0.0, -s, 0.0, c) * dir;
    c = cos(pitch); s = sin(pitch);
    dir = mat3(1.0, 0.0, 0.0, 0.0, c, -s, 0.0, s, c) * dir;
  }
  float dist = raymarch(cam, dir, freq, tc);
  vec3 pos = cam + dist * dir;
  float t = clamp(uFogDepth / max(dist, 0.001), 0.0, 1.0);
  vec3 body = mix(uWaveColor, uCrestColor, clamp(pos.z * 0.08 + 0.5, 0.0, 1.0));
  vec3 col = mix(uHorizonColor, body, t);
  col *= uBrightness;
  col = clamp(col, 0.0, 1.0);
  float alpha = clamp(t, 0.0, 1.0) * uOpacity;
  if (uGrain > 0.5) {
    float g = hash21(gl_FragCoord.xy + mod(iTime, 64.0) * 11.0);
    alpha += (g - 0.5) * uGrainIntensity;
    alpha = clamp(alpha, 0.0, 1.0);
  }
  fragColor = vec4(col * alpha, alpha);
}
`;

type GLCtx = {
  renderer: Renderer;
  program: Program;
  mesh: Mesh;
};

const ctxMap = new WeakMap<HTMLDivElement, GLCtx>();

const GradientWaves = ({
  className,
  horizonColor = "#5227FF",
  waveColor = "#FF9FFC",
  crestColor = "#FFFFFF",
  speed = 0.4,
  amplitude = 2.5,
  waveScale = 0.6,
  waveRatio = 0.9,
  swell = 35,
  turbulence = 20,
  tilt = 1.11,
  zoom = 1.0,
  height = 5.5,
  fogDepth = 15,
  detail = "medium",
  brightness = 1.0,
  opacity = 1.0,
  mouseInteraction = true,
  parallaxStrength = 0.5,
  grain = true,
  grainIntensity = 0.05,
}: GradientWavesProps) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const enableMouseRef = useRef(mouseInteraction);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const renderer = new Renderer({
      webgl: 2,
      alpha: true,
      premultipliedAlpha: true,
      antialias: false,
      dpr: Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, 2),
    });
    const gl = renderer.gl;
    gl.clearColor(0, 0, 0, 0);

    const canvas = gl.canvas;
    canvas.style.width = "100%";
    canvas.style.height = "100%";
    canvas.style.display = "block";
    container.appendChild(canvas);

    const geometry = new Triangle(gl);
    const program = new Program(gl, {
      vertex: VERTEX,
      fragment: FRAGMENT,
      uniforms: {
        iTime: { value: 0 },
        iResolution: { value: new Float32Array([1, 1]) },
        uSpeed: { value: speed },
        uAmplitude: { value: amplitude },
        uWaveScale: { value: waveScale },
        uWaveRatio: { value: waveRatio },
        uSwell: { value: swell },
        uTurbulence: { value: turbulence },
        uTilt: { value: tilt },
        uZoom: { value: zoom },
        uHeight: { value: height },
        uFogDepth: { value: fogDepth },
        uSteps: { value: detailToSteps(detail) },
        uBrightness: { value: brightness },
        uOpacity: { value: opacity },
        uGrain: { value: grain ? 1 : 0 },
        uGrainIntensity: { value: grainIntensity },
        uMouse: { value: new Float32Array([0.5, 0.5]) },
        uParallax: { value: parallaxStrength },
        uEnableMouse: { value: mouseInteraction },
        uHorizonColor: { value: new Float32Array(hexToRgb(horizonColor)) },
        uWaveColor: { value: new Float32Array(hexToRgb(waveColor)) },
        uCrestColor: { value: new Float32Array(hexToRgb(crestColor)) },
      },
    });

    const mesh = new Mesh(gl, { geometry, program });
    ctxMap.set(container, { renderer, program, mesh });

    const setSize = () => {
      // clientWidth/clientHeight reflect the untransformed layout box, unlike
      // getBoundingClientRect() which shrinks while an ancestor's mount/hover
      // scale transform is mid-animation — ogl's setSize() bakes whatever we
      // pass here into a fixed pixel canvas size, so a transformed reading
      // would permanently undersize the canvas relative to its container.
      const w = Math.max(1, container.clientWidth);
      const h = Math.max(1, container.clientHeight);
      renderer.setSize(w, h);
      const res = program.uniforms.iResolution.value as Float32Array;
      res[0] = gl.drawingBufferWidth;
      res[1] = gl.drawingBufferHeight;
      renderer.render({ scene: mesh });
    };

    const ro = new ResizeObserver(setSize);
    ro.observe(container);
    setSize();

    const currentMouse = [0.5, 0.5];
    const targetMouse = [0.5, 0.5];

    const onPointerMove = (e: PointerEvent) => {
      const rect = canvas.getBoundingClientRect();
      targetMouse[0] = (e.clientX - rect.left) / rect.width;
      targetMouse[1] = (e.clientY - rect.top) / rect.height;
    };
    const onPointerLeave = () => {
      targetMouse[0] = 0.5;
      targetMouse[1] = 0.5;
    };
    canvas.addEventListener("pointermove", onPointerMove);
    canvas.addEventListener("pointerleave", onPointerLeave);

    let raf: number | null = null;
    let isVisible = true;
    let isPageVisible = !document.hidden;
    const start = performance.now();

    const loop = (t: number) => {
      program.uniforms.iTime.value = (t - start) * 0.001;

      const tx = enableMouseRef.current ? targetMouse[0] : 0.5;
      const ty = enableMouseRef.current ? targetMouse[1] : 0.5;
      currentMouse[0] += (tx - currentMouse[0]) * 0.05;
      currentMouse[1] += (ty - currentMouse[1]) * 0.05;
      const mouseValue = program.uniforms.uMouse.value as Float32Array;
      mouseValue[0] = currentMouse[0];
      mouseValue[1] = currentMouse[1];

      renderer.render({ scene: mesh });
      raf = requestAnimationFrame(loop);
    };

    const tryStart = () => {
      if (isVisible && isPageVisible && raf === null) {
        raf = requestAnimationFrame(loop);
      }
    };
    const tryStop = () => {
      if (raf !== null) {
        cancelAnimationFrame(raf);
        raf = null;
      }
    };

    const io = new IntersectionObserver(
      ([entry]) => {
        isVisible = entry.isIntersecting;
        isVisible ? tryStart() : tryStop();
      },
      { threshold: 0 },
    );
    io.observe(container);

    const onVisibility = () => {
      isPageVisible = !document.hidden;
      isPageVisible ? tryStart() : tryStop();
    };
    document.addEventListener("visibilitychange", onVisibility);

    tryStart();

    return () => {
      tryStop();
      io.disconnect();
      ro.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerleave", onPointerLeave);
      ctxMap.delete(container);
      try {
        container.removeChild(canvas);
      } catch {
        // canvas already detached
      }
      gl.getExtension("WEBGL_lose_context")?.loseContext();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    const ctx = ctxMap.get(container);
    if (!ctx) return;
    const { program } = ctx;

    enableMouseRef.current = mouseInteraction;

    program.uniforms.uSpeed.value = speed;
    program.uniforms.uAmplitude.value = amplitude;
    program.uniforms.uWaveScale.value = waveScale;
    program.uniforms.uWaveRatio.value = waveRatio;
    program.uniforms.uSwell.value = swell;
    program.uniforms.uTurbulence.value = turbulence;
    program.uniforms.uTilt.value = tilt;
    program.uniforms.uZoom.value = zoom;
    program.uniforms.uHeight.value = height;
    program.uniforms.uFogDepth.value = fogDepth;
    program.uniforms.uSteps.value = detailToSteps(detail);
    program.uniforms.uBrightness.value = brightness;
    program.uniforms.uOpacity.value = opacity;
    program.uniforms.uGrain.value = grain ? 1 : 0;
    program.uniforms.uGrainIntensity.value = grainIntensity;
    program.uniforms.uParallax.value = parallaxStrength;
    program.uniforms.uEnableMouse.value = mouseInteraction;

    const uHorizonColor = program.uniforms.uHorizonColor.value as Float32Array;
    const uWaveColor = program.uniforms.uWaveColor.value as Float32Array;
    const uCrestColor = program.uniforms.uCrestColor.value as Float32Array;
    const [hr, hg, hb] = hexToRgb(horizonColor);
    const [wr, wg, wb] = hexToRgb(waveColor);
    const [cr, cg, cb] = hexToRgb(crestColor);
    uHorizonColor[0] = hr;
    uHorizonColor[1] = hg;
    uHorizonColor[2] = hb;
    uWaveColor[0] = wr;
    uWaveColor[1] = wg;
    uWaveColor[2] = wb;
    uCrestColor[0] = cr;
    uCrestColor[1] = cg;
    uCrestColor[2] = cb;
  }, [
    horizonColor,
    waveColor,
    crestColor,
    speed,
    amplitude,
    waveScale,
    waveRatio,
    swell,
    turbulence,
    tilt,
    zoom,
    height,
    fogDepth,
    detail,
    brightness,
    opacity,
    grain,
    grainIntensity,
    mouseInteraction,
    parallaxStrength,
  ]);

  return (
    <div
      ref={containerRef}
      className={`relative h-full w-full overflow-hidden ${className ?? ""}`}
    />
  );
};

export default GradientWaves;
```
