Skip to content

IrisBackgroundsRose Ribbon

Rose Ribbon

Silk Ribbon in wine and rose instead of amber — the same falling, twisting ribbon, a colour that lives nowhere in the token ramp.

drape

Rose Ribbon

A fixed-palette variation on `silk-ribbon`: the exact same falling, twisting geometry and pointer contract, over a deep wine-black ground, the ribbon itself running from shaded burgundy to a vivid rose where it turns face-on. The colour lives nowhere in the site's amber accent ramp, so it's a fixed palette rather than a live token read — one specific coloured piece, not a parametric recolour of the ribbon.

Pointer contract unchanged from `silk-ribbon`: presence near the cursor's height flutters the sway and speeds the twist locally, and a bright glint lands wherever the cursor meets the ribbon.

  • cool-warm
  • fabric
Family
drape
Status
Available
Licence
Free

Install

No installation needed — self-contained, paste-in code.

Usage

Drop it straight into a page.

example.tsx
import { RoseRibbonField } from "./RoseRibbonField";

export default function Example() {
  return (
    // Fills its nearest positioned ancestor (it renders itself `absolute
    // inset-0`) — give it a sized, relatively positioned box.
    <div className="relative isolate h-[32rem] w-full overflow-hidden rounded-2xl">
      <RoseRibbonField />
    </div>
  );
}

Component

The real source, exactly as it ships — multiple files, kept together.

"use client";

import { useEffect, useRef } from "react";
import { mountShaderSurface } from "@/lib/shader-surface";

/**
 * A fixed-palette sibling of `SilkRibbonField`: the exact same falling,
 * twisting ribbon geometry and pointer contract, in a deep wine-to-rose
 * colour that lives nowhere in the site's amber accent ramp. Same reasoning
 * `RainlineField`/`MotorwayField` apply against `night-road`'s token-free
 * palette — this is one specific coloured piece, not a parametric "recolour
 * the ribbon" generator, so the CSS floor carries a fixed oklch rather than
 * a var().
 *
 * One of the reusable background fields, a sibling of `SilkRibbonField` and
 * `IndigoRibbonField` in the ribbon family. Drop it into any
 * `position: relative`/`isolate` parent — it fills the box. Built on
 * `lib/shader-surface.ts`, so degradation is already handled: no WebGL, a
 * blocked or lost context, a hidden tab, or `prefers-reduced-motion` all
 * leave the CSS `.iris-roseribbonfield__floor` underneath visible.
 *
 * Reading guard: identical mechanism to `SilkField` — off by default
 * (`null`), on when `guardSelector` resolves to an element.
 */

/* Palette, sRGB 0–1. Uniforms, not tokens — see the note above. */
const PALETTE: Record<string, [number, number, number]> = {
  u_deep: [0.035, 0.008, 0.02], // the ambient wine-black ground
  u_dark: [0.3, 0.05, 0.12], // the ribbon's shaded, turned-away face — burgundy
  u_light: [0.93, 0.28, 0.48], // the ribbon's lit face — vivid rose
  u_hot: [1.0, 0.93, 0.95], // the near-white glint / specular
};

const FRAG = `
uniform vec2  u_res;
uniform float u_time;
uniform float u_scale;
uniform vec3  u_pointer;  /* x, y (0 bottom .. 1 top), presence 0..1 */

uniform vec3 u_deep;
uniform vec3 u_dark;
uniform vec3 u_light;
uniform vec3 u_hot;

uniform vec4  u_readA;
uniform float u_guard;

const float SWAY_FREQ  = 0.9;
const float SWAY_AMP   = 0.30;
const float TWIST_RATE = 5.5;
const float RIBBON_W   = 0.30;

void main() {
  vec2 res = u_res / u_scale;
  vec2 uv  = gl_FragCoord.xy / u_scale / res;          /* 0..1, y up */
  float aspect = res.x / res.y;
  vec2 P = (uv - 0.5) * vec2(aspect, 1.0);
  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);

  float flutter = u_pointer.z * exp(-pow((uv.y - u_pointer.y) * 4.0, 2.0));

  float sway = sin(uv.y * SWAY_FREQ * 6.2831853 + u_time * 0.12) * SWAY_AMP
             + fbm(vec2(uv.y * 2.2, u_time * 0.05)) * 0.05
             + flutter * sin(u_time * 7.0 + uv.y * 40.0) * 0.10;
  float centerX = sway;

  float twist  = uv.y * TWIST_RATE + u_time * 0.35 * (1.0 + flutter * 1.6);
  float facing = cos(twist);
  float halfW  = max(RIBBON_W * abs(facing), 0.004);

  float dx = P.x - centerX;
  float edge = abs(dx);
  float inRibbon = 1.0 - smoothstep(halfW * 0.86, halfW, edge);

  float fold = sin((dx / halfW) * 3.14159265 * 4.5 + uv.y * 3.0);

  vec3 faceCol = mix(u_dark, u_light, smoothstep(-0.15, 0.35, facing));
  faceCol *= 0.78 + 0.30 * (0.5 + 0.5 * fold);

  float spec = smoothstep(0.55, 1.0, facing) * (1.0 - smoothstep(0.0, halfW * 0.5, edge));
  faceCol += u_hot * spec * 0.5;

  float rim = exp(-max(edge - halfW * 0.7, 0.0) * 60.0) * smoothstep(-0.2, 0.4, facing);
  faceCol += u_light * rim * 0.35;

  vec3 bg = u_deep * (0.88 + 0.12 * (fbm(vec2(uv.x * 2.6, uv.y * 1.2 + u_time * 0.015)) * 0.5 + 0.5));

  vec3 col = mix(bg, faceCol, inRibbon);

  float distPtr = length(P - ptr);
  float onRibbonAtPtr = 1.0 - smoothstep(halfW, halfW * 1.7, abs(ptr.x - centerX));
  col += u_hot * u_pointer.z * onRibbonAtPtr * exp(-distPtr * distPtr * 60.0) * 0.6;

  col = max(col, 0.0);

  /* ---- the reading guard (see SilkField for the full rationale) ---- */
  vec2 rd = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
  float m = mix(max(rd.x, rd.y), length(rd), 0.4);
  float band = 1.0 - smoothstep(0.72, 2.1, m);
  col = mix(col, holdUnder(col, 0.09), band * u_guard);

  col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.2 / 255.0);

  gl_FragColor = vec4(col, 1.0);
}
`;

export interface RoseRibbonFieldProps {
  className?: string;
  /**
   * CSS selector for the block the reading guard should keep readable,
   * resolved against `document`. `null` (the default) turns the guard off.
   */
  guardSelector?: string | null;
}

export function RoseRibbonField({ className, guardSelector = null }: RoseRibbonFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const guardOn = guardSelector != null;
    let guardEl: Element | null | undefined;
    const readGuardEl = () => {
      if (guardEl === undefined) {
        guardEl = guardOn ? document.querySelector(guardSelector as string) : null;
      }
      return guardEl;
    };

    return mountShaderSurface(canvas, {
      fragment: FRAG,
      uniforms: [...Object.keys(PALETTE), "u_readA", "u_guard"],
      onInit: (gl, u) => {
        for (const name of Object.keys(PALETTE)) {
          if (u[name]) gl.uniform3fv(u[name], PALETTE[name]);
        }
        if (u.u_readA) gl.uniform4f(u.u_readA, 0.5, 0.5, 0.44, 0.32);
        if (u.u_guard) gl.uniform1f(u.u_guard, guardOn ? 1 : 0);
      },
      onFrame: (gl, u, s) => {
        if (!guardOn || !u.u_readA) return;
        let cx = 0.5, cy = 0.5, hw = 0.44, hh = 0.32;
        const el = readGuardEl();
        const { rect } = s;
        if (el && rect.width > 0 && rect.height > 0) {
          const r = el.getBoundingClientRect();
          const padX = rect.width * 0.09;
          const padY = rect.height * 0.11;
          cx = (r.left + r.width / 2 - rect.left) / rect.width;
          cy = 1 - (r.top + r.height / 2 - rect.top) / rect.height;
          hw = (r.width / 2 + padX) / rect.width;
          hh = (r.height / 2 + padY) / rect.height;
        }
        gl.uniform4f(u.u_readA, cx, cy, hw, hh);
      },
      onPainted: () => canvas.setAttribute("data-shader", "on"),
      onLost: () => canvas.removeAttribute("data-shader"),
      maxPixels: 2_200_000,
      dprCap: 1.5,
    });
  }, [guardSelector]);

  return (
    <div className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`} aria-hidden="true">
      <div className="absolute inset-0 [background:radial-gradient(16%_88%_at_53%_46%,oklch(0.72_0.19_8)_0%,transparent_62%),oklch(0.05_0.03_8)]" />
      <canvas ref={canvasRef} className="absolute inset-0 w-full h-full opacity-0 transition-opacity duration-[--duration-slow] ease-[--ease-standard] data-[shader=on]:opacity-100" />
    </div>
  );
}

More backgrounds

Custom work

Need one that isn't in the catalogue?

Describe what you're after and I'll reply by email — no promise on turnaround yet, this is a new channel, not a service with a set price or queue.

Fardin Omor Afnan

Fardin Omor Afnan

Reads and answers every request himself

Or start with a section.

Whole page sections, composed and ready to drop in — take one and build the rest of the page around it.

Browse sections