stream/ui
Browse the library

Jungle run hero

A monkey sprints through a parallax jungle and leaps for bananas, under a sky-high headline.

Built withlucide-react

Install

Copy it into a shadcn/ui project with the shadcn CLI, which brings the theme and anything else it needs, or open it in v0 to remix it. Or copy the source below.

bunx shadcn@latest add https://streamui.org/r/jungle-hero.json
Open in v0 (opens in a new tab)

Preview

JungleHeroSection.tsx

Go bananas on launch day.

Acme swings your launch page from idea to live, vine to vine, without dropping a single banana.

Notes

  • Hills, palms, and the ground scroll at their own speeds: each layer is drawn twice side by side and slides one copy's width, so the loop never jumps.
  • The leap and the banana share one cycle, so the monkey always peaks as the banana reaches it, and a +1 pops.
  • It has its own jungle palette as custom properties on the section. No JavaScript: a server component, and all motion holds still for reduced motion.

Source

src/components/sections/JungleHeroSection.tsx
import type { CSSProperties } from "react";
import { ArrowRight } from "lucide-react";

/**
 * A jungle palette rather than the library brights: mint sky, layered greens,
 * bark brown, and banana yellow. Set on the section as custom properties, so
 * it's one place to retune.
 */
const PALETTE = {
  "--jungle-sky": "oklch(0.94 0.045 170)",
  "--jungle-hill": "oklch(0.8 0.07 160)",
  "--jungle-leaf": "oklch(0.66 0.12 148)",
  "--jungle-leaf-dark": "oklch(0.52 0.1 152)",
  "--jungle-bark": "oklch(0.52 0.075 55)",
  "--jungle-grass": "oklch(0.74 0.14 135)",
  "--jungle-dirt": "oklch(0.64 0.08 62)",
  "--jungle-banana": "oklch(0.88 0.14 95)",
  "--jungle-fur": "oklch(0.46 0.045 45)",
  "--jungle-fur-dark": "oklch(0.36 0.04 45)",
  "--jungle-face": "oklch(0.76 0.05 60)",
} as CSSProperties;

/** One jump-and-grab cycle, in seconds; the ape's leap peaks as the banana reaches it. */
const CYCLE = 3;
const PEAK = 0.68; // fraction of the cycle

/** Rolling hills: the ridge line (outlined) and the same ridge closed into a shape (filled), so tiles join without seams. */
const RIDGE = "M0 50 C 60 10, 140 10, 200 50 C 260 85, 340 15, 400 50";
const HILLS = `${RIDGE} L400 100 L0 100 Z`;

/** A curved banana with brown tips, in a 80×50 box. */
function Banana({ className, style }: { className?: string; style?: CSSProperties }) {
  return (
    <svg viewBox="0 0 80 50" overflow="visible" className={className} style={style} aria-hidden>
      <path
        d="M8 14 C 14 40, 58 46, 74 12 C 62 26, 26 30, 12 10 Z"
        fill="var(--jungle-banana)"
        stroke="var(--ink)"
        strokeWidth="4"
        strokeLinejoin="round"
      />
      <path d="M8 14 L4 8" stroke="var(--jungle-bark)" strokeWidth="5" strokeLinecap="round" />
      <circle cx="75" cy="11" r="2.5" fill="var(--jungle-bark)" />
    </svg>
  );
}

/** A palm: a ringed, slightly leaning trunk under a crown of leaves. */
function Palm({ left, height, lean }: { left: string; height: number; lean: number }) {
  return (
    <div className="absolute bottom-0 w-20 origin-bottom" style={{ left, height, rotate: `${lean}deg` }}>
      <span
        className="absolute bottom-0 left-1/2 block h-full w-4 -translate-x-1/2 border-x-[3px] border-ink"
        style={{ background: "repeating-linear-gradient(var(--jungle-bark) 0 14px, var(--ink) 14px 17px)" }}
      />
      <svg viewBox="0 0 100 60" overflow="visible" className="absolute -top-8 left-1/2 w-32 -translate-x-1/2">
        <g stroke="var(--ink)" strokeWidth="3.5" strokeLinejoin="round">
          <path d="M50 30 C 30 8, 8 12, 0 34 C 18 24, 34 26, 50 30 Z" fill="var(--jungle-leaf-dark)" />
          <path d="M50 30 C 70 8, 92 12, 100 34 C 82 24, 66 26, 50 30 Z" fill="var(--jungle-leaf-dark)" />
          <path d="M50 30 C 38 0, 62 0, 50 30 Z" fill="var(--jungle-leaf)" />
          <path d="M50 30 C 28 30, 14 44, 12 58 C 26 44, 38 38, 50 30 Z" fill="var(--jungle-leaf)" />
          <path d="M50 30 C 72 30, 86 44, 88 58 C 74 44, 62 38, 50 30 Z" fill="var(--jungle-leaf)" />
        </g>
      </svg>
    </div>
  );
}

/** The trees on one half of the scrolling strip; the strip draws it twice so the loop never jumps. */
const PALMS = [
  { left: "6%", height: 150, lean: -4 },
  { left: "31%", height: 190, lean: 3 },
  { left: "58%", height: 135, lean: -2 },
  { left: "80%", height: 175, lean: 5 },
];

/** A tapered limb from (x1, y1) to (x2, y2), w1 wide at the root and w2 at the end, with round ends. */
function limb(x1: number, y1: number, x2: number, y2: number, w1: number, w2: number) {
  const a = Math.atan2(y2 - y1, x2 - x1);
  const nx = -Math.sin(a);
  const ny = Math.cos(a);
  const p = (x: number, y: number, w: number, side: 1 | -1) => `${(x + (nx * w * side) / 2).toFixed(1)} ${(y + (ny * w * side) / 2).toFixed(1)}`;
  return `M${p(x1, y1, w1, 1)} L${p(x2, y2, w2, 1)} A${w2 / 2} ${w2 / 2} 0 0 0 ${p(x2, y2, w2, -1)} L${p(x1, y1, w1, -1)} A${w1 / 2} ${w1 / 2} 0 0 0 ${p(x1, y1, w1, 1)} Z`;
}

const FAR_ARM = limb(64, 66, 48, 86, 10, 8);
const FAR_LEG = limb(58, 96, 44, 120, 12, 10);
const NEAR_LEG = limb(64, 98, 78, 120, 12, 10);
const NEAR_ARM = limb(70, 66, 92, 78, 10, 8);

/**
 * The runner: a monkey sprinting upright to the right. A long curly tail, big
 * round ears, a heart-shaped face with big eyes and a grin, long arms
 * pumping against the legs, and the tails of a banana-yellow headband
 * flapping behind.
 */
function Ape() {
  const fur = "var(--jungle-fur)";
  const back = "var(--jungle-fur-dark)";
  const face = "var(--jungle-face)";
  return (
    <svg viewBox="0 0 130 130" overflow="visible" className="size-full" aria-hidden>
      <g stroke="var(--ink)" strokeWidth="4" strokeLinejoin="round" strokeLinecap="round">
        {/* The tail: an ink line with fur down its middle, curling up at the tip. */}
        <g className="jungle-hero-tail" style={{ transformOrigin: "50px 94px" }}>
          <path d="M50 94 C 26 100, 8 86, 12 66 C 16 48, 36 48, 36 62 C 36 72, 24 72, 25 64" fill="none" strokeWidth="11" />
          <path d="M50 94 C 26 100, 8 86, 12 66 C 16 48, 36 48, 36 62 C 36 72, 24 72, 25 64" fill="none" stroke={fur} strokeWidth="4.5" />
        </g>

        {/* Far limbs, in shadow. */}
        <g className="jungle-hero-limb-b" style={{ transformOrigin: "58px 96px" }}>
          <path d={FAR_LEG} fill={back} />
          <ellipse cx="41" cy="122" rx="9" ry="5" fill={face} />
        </g>
        <g className="jungle-hero-limb-a" style={{ transformOrigin: "64px 66px" }}>
          <path d={FAR_ARM} fill={back} />
          <circle cx="47" cy="88" r="5" fill={face} />
        </g>

        {/* Body and belly. */}
        <ellipse cx="62" cy="82" rx="18" ry="22" fill={fur} transform="rotate(12 62 82)" />
        <ellipse cx="67" cy="86" rx="10" ry="14" fill={face} strokeWidth="3" transform="rotate(12 67 86)" />

        {/* Near leg. */}
        <g className="jungle-hero-limb-a" style={{ transformOrigin: "64px 98px" }}>
          <path d={NEAR_LEG} fill={fur} />
          <ellipse cx="82" cy="122" rx="9" ry="5" fill={face} />
        </g>

        {/* Headband tails, flapping behind the head. */}
        <g className="jungle-hero-flap" style={{ transformOrigin: "56px 30px" }}>
          <path d="M57 30 L36 21 L40 30 L34 37 Z" fill="var(--jungle-banana)" strokeWidth="3" />
        </g>

        {/* Head: a big ear, the round head, a heart-shaped face, eyes, nose, and a grin. */}
        <circle cx="51" cy="44" r="11" fill={fur} />
        <circle cx="51" cy="44" r="5.5" fill={face} strokeWidth="3" />
        <circle cx="72" cy="40" r="22" fill={fur} />
        <path d="M66 32 C 64 21, 79 19, 82 28 C 85 19, 100 22, 97 33 C 103 40, 100 55, 86 57 C 73 58, 63 50, 66 40 C 64 37, 64 34, 66 32 Z" fill={face} strokeWidth="3" />
        <circle cx="76" cy="33" r="5" fill="var(--paper-raised)" strokeWidth="2.5" />
        <circle cx="90" cy="33" r="5" fill="var(--paper-raised)" strokeWidth="2.5" />
        <circle cx="77.8" cy="33.6" r="2.4" fill="var(--ink)" stroke="none" />
        <circle cx="91.8" cy="33.6" r="2.4" fill="var(--ink)" stroke="none" />
        <circle cx="86" cy="43" r="1.4" fill="var(--ink)" stroke="none" />
        <circle cx="91" cy="43" r="1.4" fill="var(--ink)" stroke="none" />
        <path d="M78 48 C 83 54, 92 54, 97 47" fill="none" strokeWidth="3" />
        <path d="M54 30 C 62 17, 84 13, 95 21 L93 26 C 84 20, 66 22, 57 35 Z" fill="var(--jungle-banana)" strokeWidth="3" />

        {/* Near arm, pumping forward. */}
        <g className="jungle-hero-limb-b" style={{ transformOrigin: "70px 66px" }}>
          <path d={NEAR_ARM} fill={fur} />
          <circle cx="94" cy="79" r="5.5" fill={face} />
        </g>
      </g>
    </svg>
  );
}

/**
 * Jungle run hero: a monkey runs through a parallax jungle (hills, palms,
 * hanging vines, and the ground scrolling at their own speeds), leaping to
 * grab a banana every few seconds.
 * The headline and buttons sit in the sky above it. No JavaScript: a server
 * component, with CSS animation that holds still for reduced motion.
 */
export function JungleHeroSection({ headingAs: Heading = "h1" }: { headingAs?: "h1" | "h2" | "h3" }) {
  const peakAt = `${(PEAK * 100).toFixed(1)}%`;
  return (
    <section
      className="@container relative isolate overflow-hidden border-b-4 border-ink text-ink"
      style={{ ...PALETTE, backgroundColor: "var(--jungle-sky)" }}
    >
      <style href="jungle-hero" precedence="default">{`
        @keyframes jungle-hero-scroll { to { translate: -50% 0; } }
        @keyframes jungle-hero-stride { 0%, 100% { rotate: -22deg; } 50% { rotate: 22deg; } }
        @keyframes jungle-hero-flap { 0%, 100% { rotate: 0deg; } 50% { rotate: -14deg; } }
        @keyframes jungle-hero-tail { 0%, 100% { rotate: -6deg; } 50% { rotate: 8deg; } }
        @keyframes jungle-hero-bob { 0%, 100% { translate: 0 0; } 50% { translate: 0 -5px; } }
        @keyframes jungle-hero-leap {
          0%, ${((PEAK - 0.16) * 100).toFixed(1)}%, 100% { translate: 0 0; rotate: 0deg; }
          ${peakAt} { translate: 0 calc(var(--leap) * -1); rotate: -8deg; }
          ${((PEAK + 0.16) * 100).toFixed(1)}% { translate: 0 0; rotate: 0deg; }
        }
        @keyframes jungle-hero-banana {
          0% { left: 108%; opacity: 1; }
          ${peakAt} { left: calc(var(--ape-x) + 3rem); opacity: 1; }
          ${((PEAK + 0.001) * 100).toFixed(1)}%, 100% { left: calc(var(--ape-x) + 3rem); opacity: 0; }
        }
        @keyframes jungle-hero-plus {
          0%, ${peakAt} { opacity: 0; translate: 0 0; scale: .5; }
          ${((PEAK + 0.04) * 100).toFixed(1)}% { opacity: 1; scale: 1.2; }
          ${((PEAK + 0.22) * 100).toFixed(1)}% { opacity: 1; translate: 0 -24px; scale: 1; }
          ${((PEAK + 0.28) * 100).toFixed(1)}%, 100% { opacity: 0; translate: 0 -30px; }
        }
        @keyframes jungle-hero-sway { 0%, 100% { rotate: -3deg; } 50% { rotate: 3deg; } }
        .jungle-hero-hills { animation: jungle-hero-scroll 36s linear infinite; }
        .jungle-hero-palms { animation: jungle-hero-scroll 14s linear infinite; }
        .jungle-hero-ground { animation: jungle-hero-scroll 5s linear infinite; }
        .jungle-hero-runner { animation: jungle-hero-leap ${CYCLE}s cubic-bezier(.3,0,.4,1) infinite; }
        .jungle-hero-body { animation: jungle-hero-bob .32s ease-in-out infinite; }
        .jungle-hero-limb-a { animation: jungle-hero-stride .32s ease-in-out infinite; }
        .jungle-hero-limb-b { animation: jungle-hero-stride .32s ease-in-out -.16s infinite; }
        .jungle-hero-banana { animation: jungle-hero-banana ${CYCLE}s linear infinite; }
        .jungle-hero-plus { animation: jungle-hero-plus ${CYCLE}s ease-out infinite; }
        .jungle-hero-flap { animation: jungle-hero-flap .2s ease-in-out infinite; }
        .jungle-hero-tail { animation: jungle-hero-tail .64s ease-in-out infinite; }
        .jungle-hero-vine { animation: jungle-hero-sway 5s ease-in-out infinite; transform-origin: top center; }
        @media (prefers-reduced-motion: reduce) {
          .jungle-hero-hills, .jungle-hero-palms, .jungle-hero-ground, .jungle-hero-runner, .jungle-hero-body,
          .jungle-hero-limb-a, .jungle-hero-limb-b, .jungle-hero-banana, .jungle-hero-plus, .jungle-hero-flap, .jungle-hero-tail, .jungle-hero-vine { animation: none; }
          .jungle-hero-banana { left: 60%; }
          .jungle-hero-plus { opacity: 0; }
        }
      `}</style>

      {/* The scene, behind the copy: everything decorative. */}
      <div aria-hidden className="absolute inset-0 -z-10" style={{ "--ape-x": "16%", "--leap": "6.5rem" } as CSSProperties}>
        {/* A low sun. */}
        <span className="absolute top-[42%] left-[9%] hidden size-20 rounded-full border-4 border-ink @3xl:block @3xl:size-24" style={{ backgroundColor: "var(--jungle-banana)" }} />

        {/* Far hills, slowest. */}
        <div className="jungle-hero-hills absolute bottom-16 flex h-40 w-[200%] @3xl:bottom-20 @3xl:h-48">
          {[0, 1].map((k) => (
            <svg key={k} viewBox="0 0 400 100" preserveAspectRatio="none" className="h-full w-1/2">
              <path d={HILLS} fill="var(--jungle-hill)" />
              <path d={RIDGE} fill="none" stroke="var(--ink)" strokeWidth="3" vectorEffect="non-scaling-stroke" />
            </svg>
          ))}
        </div>

        {/* Palms, middle speed. */}
        <div className="jungle-hero-palms absolute bottom-16 flex h-56 w-[200%] @3xl:bottom-20">
          {[0, 1].map((k) => (
            <div key={k} className="relative h-full w-1/2">
              {PALMS.map((p) => (
                <Palm key={p.left} {...p} />
              ))}
            </div>
          ))}
        </div>

        {/* Vines hanging from the top edge, swaying; only on wide scenes, where they clear the headline. */}
        {[3, 10, 90, 97].map((x, i) => (
          <span
            key={x}
            className="jungle-hero-vine absolute top-0 hidden w-2 border-x-[3px] border-ink @6xl:block"
            style={{ left: `${x}%`, height: `${[30, 16, 20, 34][i]}%`, backgroundColor: "var(--jungle-leaf)", animationDelay: `${-i * 1.3}s` }}
          >
            <span className="absolute -bottom-3 -left-3 block size-6 rounded-full border-[3px] border-ink" style={{ backgroundColor: "var(--jungle-leaf-dark)" }} />
          </span>
        ))}

        {/* The ground, fastest: grass on top, hatched dirt below. */}
        <div className="jungle-hero-ground absolute bottom-0 flex h-16 w-[200%] @3xl:h-20">
          {[0, 1].map((k) => (
            <div
              key={k}
              className="h-full w-1/2 border-t-4 border-ink"
              style={{
                backgroundColor: "var(--jungle-dirt)",
                backgroundImage:
                  "linear-gradient(var(--jungle-grass) 0 14px, var(--ink) 14px 17px, transparent 17px), repeating-linear-gradient(-45deg, transparent 0 14px, color-mix(in oklab, var(--ink) 18%, transparent) 14px 17px)",
              }}
            />
          ))}
        </div>

        {/* The runner, on the grass. */}
        <div className="absolute bottom-[3.25rem] size-28 @3xl:bottom-[4.25rem] @3xl:size-36" style={{ left: "var(--ape-x)" }}>
          <div className="jungle-hero-runner size-full origin-bottom">
            <div className="jungle-hero-body size-full">
              <Ape />
            </div>
          </div>
        </div>

        {/* The banana it leaps for, and the +1 when it lands in hand. */}
        <Banana
          className="jungle-hero-banana absolute bottom-[12rem] w-14 -rotate-12 @3xl:bottom-[14.5rem] @3xl:w-16"
          style={{ left: "60%" }}
        />
        <span
          className="jungle-hero-plus absolute bottom-[13.5rem] border-[3px] border-ink bg-paper-raised px-1.5 font-mono text-sm font-bold opacity-0 shadow-[3px_3px_0_var(--ink)] @3xl:bottom-[16rem]"
          style={{ left: "calc(var(--ape-x) + 3.5rem)" }}
        >
          +1
        </span>

      </div>

      {/* The copy, in the sky. Bottom padding keeps it clear of the jungle. */}
      <div className="relative mx-auto flex max-w-3xl flex-col items-center px-4 pt-20 pb-80 text-center @3xl:pt-24 @3xl:pb-96">
        <Heading className="font-display text-4xl leading-[1.02] tracking-tight @md:text-5xl @3xl:text-7xl">
          Go{" "}
          <span className="inline-block -rotate-2 border-4 border-ink px-3 shadow-[6px_6px_0_var(--ink)]" style={{ backgroundColor: "var(--jungle-banana)" }}>
            bananas
          </span>{" "}
          on launch day.
        </Heading>
        <p className="mt-6 max-w-xl text-lg leading-relaxed @3xl:text-xl">
          Acme swings your launch page from idea to live, vine to vine, without dropping a single banana.
        </p>
        <div className="mt-8 flex flex-wrap justify-center gap-3">
          <a
            href="#"
            className="inline-flex h-13 items-center gap-2 border-4 border-ink bg-ink px-6 font-mono text-sm font-bold text-paper uppercase shadow-[5px_5px_0_var(--jungle-leaf-dark)] transition-[translate,box-shadow] duration-150 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_var(--jungle-leaf-dark)] active:translate-x-1 active:translate-y-1 active:shadow-none focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-blue motion-reduce:transition-none"
          >
            Start swinging <ArrowRight aria-hidden className="size-4" />
          </a>
          <a
            href="#"
            className="inline-flex h-13 items-center border-4 border-ink bg-paper-raised px-6 font-mono text-sm font-bold uppercase shadow-[5px_5px_0_var(--ink)] transition-[translate,box-shadow] duration-150 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_var(--ink)] active:translate-x-1 active:translate-y-1 active:shadow-none focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-blue motion-reduce:transition-none"
          >
            See the code
          </a>
        </div>
      </div>
    </section>
  );
}