Christian Katzmann

Glyph Portal

Heroes 49

About

Choose a letter, then scroll through it into your next section. A self-contained React component for gradients, images, video, and canvas backgrounds, with native scroll and a complete reduced-motion layout.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Glyph Portal to your project and fits it to your theme.

Code

How Glyph Portal is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import { useEffect, useRef } from "react";
import GlyphPortal from "@/components/ui/glyph-portal";

const settings = { word: "TIDE", scrollLength: 2.4, annotations: false };

/** A live canvas, owned by the demo. The portal never copies or rasterizes it. */
function TideField() {
  const ref = useRef<HTMLCanvasElement>(null);
  useEffect(() => {
    const canvas = ref.current!;
    const context = canvas.getContext("2d");
    if (!context) return;
    const motion = matchMedia("(prefers-reduced-motion: reduce)");
    let frame = 0, visible = false, width = 1, height = 1;
    const draw = (time: number) => {
      frame = 0;
      const t = motion.matches ? 0 : time / 8000;
      context.fillStyle = "#092c2c";
      context.fillRect(0, 0, width, height);
      const glow = context.createRadialGradient(width * .72, height * .2, 0, width * .5, height * .4, width * .8);
      glow.addColorStop(0, "#246454"); glow.addColorStop(1, "#082b2c");
      context.fillStyle = glow;
      context.fillRect(0, 0, width, height);
      for (let line = -6; line < 46; line++) {
        context.beginPath();
        for (let x = -8; x <= width + 8; x += 8) {
          const u = x / width;
          const bend = Math.sin(u * 5 + t + line * .035) * height * .13 + Math.cos(u * 9 - t * .7) * height * .045;
          const y = line * height / 35 + bend;
          if (x === -8) context.moveTo(x, y); else context.lineTo(x, y);
        }
        context.strokeStyle = line % 5 === 0 ? "#94b995" : "#4b8772";
        context.globalAlpha = line % 5 === 0 ? .3 : .2;
        context.lineWidth = line % 5 === 0 ? 1.2 : .7;
        context.stroke();
      }
      context.globalAlpha = 1;
      context.fillStyle = "rgba(0,18,16,.1)";
      context.fillRect(0, 0, width, height);
      if (visible && !motion.matches && !document.hidden) frame = requestAnimationFrame(draw);
    };
    const refresh = () => {
      cancelAnimationFrame(frame);
      const box = canvas.getBoundingClientRect();
      width = box.width; height = box.height;
      const ratio = Math.min(devicePixelRatio, 2);
      canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio);
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
      draw(performance.now());
    };
    const resize = new ResizeObserver(refresh);
    resize.observe(canvas);
    const observer = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; refresh(); });
    observer.observe(canvas);
    motion.addEventListener("change", refresh);
    document.addEventListener("visibilitychange", refresh);
    refresh();
    return () => {
      cancelAnimationFrame(frame); resize.disconnect(); observer.disconnect();
      motion.removeEventListener("change", refresh);
      document.removeEventListener("visibilitychange", refresh);
    };
  }, []);
  return <canvas ref={ref} style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} />;
}

export default function CanvasDemo(props: Partial<typeof settings>) {
  const s = { ...settings, ...props };
  return (
    <div data-demo-scroll tabIndex={0} role="region" aria-label="Tide canvas portal demo. Scroll to move through the word."
      style={{ width: "100%", height: "min(720px, 100svh)", overflowY: "auto", background: "#fff", containerType: "inline-size" }}>
      <GlyphPortal word={s.word} scrollLength={s.scrollLength} annotations={s.annotations}
        style={{ "--gp-paper": "#fff", "--gp-ink": "#203e36", "--gp-field": "#092c2c", "--gp-foreground": "#f4f0d7" }} background={<TideField />}>
        <div style={{ maxWidth: 850 }}>
          <p style={{ color: "inherit", font: "12px/1.5 ui-monospace, monospace", letterSpacing: ".1em", textTransform: "uppercase", margin: "0 0 28px" }}>Live canvas · Tide</p>
          <h2 style={{ color: "inherit", fontFamily: "Georgia, serif", fontWeight: 400, fontSize: "clamp(36px, 7cqw, 84px)", lineHeight: 1.02, letterSpacing: "-.045em", margin: "0 0 28px", maxWidth: "14ch", textWrap: "balance" }}>Go with the current.</h2>
          <p style={{ color: "inherit", font: "16px/1.65 Arial, sans-serif", maxWidth: "38ch", margin: 0 }}>A canvas keeps drawing behind the letters, throughout the passage, and on the other side.</p>
        </div>
      </GlyphPortal>
    </div>
  );
}

More by Christian Katzmann

View all
  • Earth Blaze

    Earth Blaze

    135
  • Driftwall

    Driftwall

    4
  • Marginalia

    Marginalia

    1
  • Mash Galore

    Mash Galore

    1
  • green mesh

    green mesh

More in Heroes

View all
  • SaaS Template

    SaaS Template

    2.4K
  • Centered Hero with Image Fan

    Centered Hero with Image Fan

    930
  • Hero

    Hero

    1.2K
  • Responsive Hero Banner

    Responsive Hero Banner

    2K
  • Minimalist Hero Fashion

    Minimalist Hero Fashion

    285
  • Home Section

    Home Section

    66
  • Hero Section 6

    Hero Section 6

    881
  • Hero Golden Spiral

    Hero Golden Spiral

    101