# Infall — Black Hole Hero Section (React + WebGL)

> A React hero section with a black hole ray-traced live in WebGL: real light bending, a scroll dive into the event horizon, and light and dark themes.

- Kind: Component (Backgrounds)
- Price: comes with the Pro plan
- License: UpFork Standard License. Use it in unlimited personal and commercial projects. Don't resell or redistribute it on its own.
- Developer: UpFork
- Tags: background, hero, shader, black-hole, threejs, webgl, 3d, ray-tracing, scroll-animation, nextjs, dark-mode
- npm packages: three
- Published: 2026-10-04
- Updated: 2026-10-04
- Page: https://upfork.dev/products/black-hole-hero

## How to get it

Install it into a React + Tailwind project with the shadcn CLI:

```bash
npx shadcn@latest add @upfork/black-hole-hero
```

Every install uses the member's personal CLI token (UPFORK_TOKEN), made on their UpFork account page. It comes with UpFork's Pro plan and the plans after it.

## About

A landing-page hero built round a black hole that is ray-traced live in the browser. Milestones 1 to 3 made the black
hole, its dust and its opening, and the hero around it (navigation, headline, buttons, readouts, three layouts, light
and dark themes). Milestone 4 added the dive (scrolling on, the camera falls into the hole), the fallbacks and the
UpFork listing.

**The black hole**

- **Real light bending:** every pixel follows its ray of light round a Schwarzschild black hole. The arch over
  the top is the far side of the disk, bent up and over. The crescent under the shadow is light that went
  round underneath. Stars behind are lensed too. All of it comes from the physics, not from a painting.
- **A disk that turns:** inner gas laps outer gas at Kepler speeds. Each thread orbits rigidly at its own
  speed, so the pattern never winds up into noise, however long the page stays open, and soft pulses of light run
  along each thread a little faster than its gas, so the lines flow (`shimmer`).
- **Light-trail threads:** distinct bright threads with dark gaps between them. Each has its own width and a
  brightness that swells and fades along its length. Threads thinner than a pixel blur into an even glow
  instead of shimmering.
- **Gold dust:** tens of thousands of sparks on Kepler orbits along a thin band, laid out unevenly the way dust lies:
  crowded in some stretches and thin in others, in clusters of every size drawn out into streaks. Each is
  bent by the thin-lens equation, so nothing behind the hole shows through its shadow. Sparks far from the
  focus (the hole) open into soft bokeh discs. The band shows mostly to the sides of the hole, as in a
  long-lens photograph.
- **Dust lane:** a blue-grey band along the disk's plane, like the Milky Way seen from inside the galaxy. It is
  drawn from each ray's elevation above that plane (tilted a little against the disk), so it stays straight
  while the camera moves.
- **The opening:** stars first. Then the gas lights from the inside out: the crescent under the shadow, then the
  inner disk, then the band. The light runs on through the dust and the dust lane arrives last.
- **Pointer swing:** with a mouse or pen, the camera leans a few degrees toward the pointer and eases back when it
  leaves. The bending is recomputed every frame, so the arch and crescent change shape as you move.
- **Film look:** HDR, Doppler brightening (one side runs hotter), a warm bloom pyramid, ACES or AgX tone
  mapping, grain and a one-level dither so dark gradients never band.

**The hero**

- **Navigation:** logo, links, *Sign in* and a *Get started* pill. Under 900 px the links fold into a menu that
  closes on a click outside it or on Escape.
- **Headline:** a serif headline whose last words are set in a gradient italic, with a line above it (a tag and a
  phrase, optionally a link) and a line under it. Everything comes in word by word, in CSS only, so the text is in
  the server HTML and readable without JavaScript.
- **The eclipse button:** an ivory button that turns into a little black hole when hovered: a dark disc grows from
  its middle with a soft edge, a bright arc orbits its rim like light round the hole, and a crescent glows
  underneath. While it is hovered, the big disk flares and turns faster, easing in and out.
- **The film button:** frosted glass whose light, inside and on its rim, follows the pointer, with a gold dot
  orbiting its play mark.
- **Borders that hold up:** every button's border is a gradient ring cut out by a mask, so it keeps the same width
  all the way round the curves and stays crisp at any zoom.
- **The dive:** scrolling on, the hero stays pinned while the camera falls in. The words rise and fade; the camera
  falls faster and faster, rising over the disk and turning round the hole as matter does on its way in, while the
  golden ring sweeps out past the edges of the screen. The light reddens and dies at the horizon, and the page's
  background opens out of the dark from the middle (on paper, like light at the end of a tunnel), where the next
  section starts. It is driven by the scroll position, eased, so a wheel's steps never jump, and reversible.
- **Live readouts:** the camera's distance (it falls during the dive), its inclination and the disk's speed in one
  corner (they follow the pointer swing and the boost), a scroll cue in the other.
- **A word on the sky (optional):** `backdropWord` paints a word far behind the hole, bent round it by gravity like
  the stars. Off by default.
- **Three layouts:** wide (desktop), tall (tablet) and phone. Each frames the black hole differently: field of
  view, where the hole sits, how far the disk turns, how many sparks.
- **Light, dark or auto:** `theme="auto"` follows a `.dark` class or `data-theme="dark"` on the page. The light
  theme draws the same picture in sepia ink on paper. The hole and the dark gap round it stay a window onto the
  night: black, with the band and the crescent glowing in it, like an eclipse. Switching themes cross-fades.

**Fast and polite**

- **Off the main thread:** the black hole renders in a Web Worker on an OffscreenCanvas. Creating the GPU context,
  compiling the shaders, working out the light paths and every frame happen there, so the page's own thread never
  waits on them (no long tasks while the page loads). The worker starts as soon as the page's script runs, before
  the hero mounts. Where a browser can't draw from a worker, the black hole falls back to the main thread by
  itself.
- **Cheap frames:** the light paths are tabulated once (a fraction of a second, on the CPU) and the noise is baked once (on
  the GPU), so a frame is a few texture reads per pixel. A governor trades traced resolution for a steady frame
  rate. It starts from what an Intel Iris Plus laptop holds at 60 fps and follows the measured frame rate; the
  composite always runs at full resolution.
- **When it can't run:** without WebGL 2 the box shows a drawing of the black hole instead (SVG, no image files),
  framed and themed like the live one; it follows the dive too. If the GPU takes the context away (a driver reset,
  the laptop waking, too many WebGL pages open), the drawing stands in and the black hole starts again by itself,
  fully lit (up to three times). The canvas stays hidden until its first frame, so a light page never shows a
  black box while it starts.
- **Polite:** rendering pauses off screen and in hidden tabs, and past the horizon, where nothing is lit. The opening
  waits until the words are in, so the two entrances never fight for the GPU. With `prefers-reduced-motion` the words
  appear without moving, the black hole is one still frame, fully lit, with no opening and no swing, and the hero is
  one screen tall, with no dive.

Built with Next.js 16 (App Router), React 19, three.js, Tailwind CSS 4 and TypeScript.

## Run it

```bash
pnpm install
pnpm build
pnpm start
```

- <http://localhost:3000> shows the hero. Scroll down for the dive. The section it lands in and the switch near the
  top (light, dark or your system's theme) are the demo's, not part of the hero.
- <http://localhost:3000/launch> and <http://localhost:3000/studio> are the same hero with other words: a launch page
  with a waiting list (Darkwell) and a design studio (Lensmark). The copy is in
  `components/sections/infall-hero/data.ts`.
- <http://localhost:3000/compare> puts the live render and a reference picture in one frame, with a divider
  you can drag. **Space** flips between the two whole pictures, **S** goes back to the split, and **G** opens a
  tuning panel with every setting. *Copy changed settings* puts the values you moved on the clipboard as JSON.
  The reference is read from `public/_reference/black-hole.webp`. That folder is git-ignored and never ships;
  without the file, the page says so and shows the render alone.

`next dev` works too, but on a laptop it can take a while to compile the three.js chunk; judge motion and
timing on the production build. `pnpm lint` runs ESLint.

## The UpFork listing

UpFork lists components through its code editor, which takes flat `.ts`/`.tsx` files (no CSS, no pictures, at most
20) and builds a live preview from pasted demos. `node scripts/registry.mjs` writes that version into
`registry/components/`: the 17 files as `components/infall-*.ts(x)`, importing each other as `@/components/infall-*`,
the workers addressed next to the files that start them, and the hero's stylesheet as a string it renders in a
`<style>` element. The demos are in `registry/demos/` (written by hand), and `LISTING.md` has the listing's title,
summary, description, categories and tags. Run the script again after changing the source.

## Use it

The whole hero:

```tsx
import { InfallHero } from "@/components/sections/infall-hero/infall-hero";

<InfallHero
  brand={{ name: "Acme", href: "/" }}
  nav={[{ label: "Pricing", href: "/pricing" }]}
  getStarted={{ label: "Get started", href: "/signup" }}
  eyebrow={{ tag: "New", text: "version 2 is out", href: "/blog/v2" }}
  title="Everything falls into place."
  description="One line on what your product does."
  primaryCta={{ label: "Start free", href: "/signup" }}
  secondaryCta={{ label: "Watch the film", href: "#film" }}
/>;
```

`components/sections/infall-hero/data.ts` has the demo's full copy, and `types.ts` documents every prop:

| Prop | What it does |
| --- | --- |
| `brand` | The product's name and where the logo links. |
| `nav`, `signIn`, `getStarted` | The navigation bar's links and its two buttons (all optional). |
| `eyebrow` | The line above the headline: `tag`, `text` and an optional `href`. |
| `title`, `highlight` | The headline, and the words of it set in the gradient italic (default: the last two). |
| `description` | The line under the headline. |
| `primaryCta`, `secondaryCta` | The eclipse button and the film button. |
| `hud` | `{ label, scrollCue }` for the corner readouts, or `false` for none. |
| `backdropWord` | A word painted behind the hole and bent round it (default: none). |
| `theme` | `"auto"` (default), `"light"` or `"dark"`. |
| `dive` | The scroll dive (default `true`). The pinned stretch is `150svh`; set `--ifh-dive-length` on the hero to change it. |
| `blackHole` | Overrides for any black hole setting, on top of the layout's framing. |
| `headingAs` | `"h1"` (default) or `"h2"` if the page already has an `h1`. |

The black hole alone:

```tsx
import { BlackHole } from "@/components/black-hole/BlackHole";

<section className="relative h-svh">
  <BlackHole className="absolute inset-0" settings={{ roll: -10, doppler: 0.3 }} />
</section>;
```

The box is sized by `className` and the canvas fills it. `settings` overrides any default in
`components/black-hole/engine/settings.ts`, and later changes apply live. Other props:

- `intro={false}` starts fully lit.
- `parallax={false}` keeps the camera still.
- `adaptive={false}` traces every pixel whatever the frame rate (useful for stills).
- `startAfter={1500}` holds the first frame until 1.5 s after the page started loading.

`onReady` hands you the engine (`setSettings`, `getSettings`, `setActive`, `setPointer`, `setBoost`, `setDive`, `getView`,
`replayIntro`, `dispose`). `onStats` reports the frame rate once a second.

## How it works

| File | What it holds |
| --- | --- |
| `black-hole/engine/geodesics.ts` | The light-path tables. Paths from infinity are integrated with RK4 in the Binet equation `u'' = 1.5u² − u`, one per impact parameter. The rows crowd in near the critical `b = 3√3/2`, and each path is resampled along its own length. Only the camera's starting angle depends on its distance. |
| `black-hole/engine/shaders.ts` | The bake passes (thread table, slow fields), the scene pass, the bloom pyramid and the composite (with the light theme). Per pixel, the scene finds where the ray pierces the disk plane: every π along its path, read from the tables. It shades each crossing front to back, then the stars and the backdrop word where the light came from. |
| `black-hole/engine/sparkles.ts` | The gold dust: clustered sparks on Kepler orbits, projected with the ray tracer's camera, bent by the thin-lens equation, drawn as points or bokeh discs into the HDR frame. |
| `black-hole/engine/core.ts` | The renderer, with no DOM: render targets, the passes, the camera and its swing, the opening, the boost, the theme, the frame-rate governor and the loop. It runs in the worker or, as a fallback, on the page. |
| `black-hole/engine/render.worker.ts` | The worker that runs the core on an OffscreenCanvas. |
| `black-hole/engine/prepare.ts` | Starts that worker as soon as the page's script runs (it has no three.js in it, so it loads at once). |
| `black-hole/engine/engine.ts` | What the page talks to: takes over the prepared worker (or starts one, or falls back to the main thread), passes sizes, settings and pointer moves on, and draws the backdrop word with the page's own fonts. |
| `black-hole/engine/tables.ts` | Works the light paths out in a worker of their own when the core runs on the page. |
| `black-hole/engine/settings.ts` | Every setting, documented, with the tuned defaults. |
| `black-hole/BlackHole.tsx` | The React wrapper: pausing off screen, the pointer, reduced motion, the stand-in and starting again after a lost context. |
| `black-hole/stand-in.tsx` | The drawing shown without WebGL 2 or while the black hole starts again: an SVG framed from the same settings. |
| `sections/infall-hero/` | The hero: `infall-hero.tsx` puts it together (with the dive, and the framing for each layout), `parts.tsx` holds its parts (the mark, the bar, the headline, the two buttons, the readouts), `types.ts` its props, `data.ts` the demos' words, and `infall-hero.css` its styles and both themes. |

Lengths are in Schwarzschild radii (horizon 1, photon sphere 1.5). The picture is tuned for looks where
physics and taste disagree. The disk is cut off well inside its hottest ring (`innerRadius` 1.7, `hotRadius` 2.6), so
the arch over the top hugs the shadow and the ring under it carries on the same circle. The crescent under the shadow is kept smooth and shown only below the band, and the
photon ring (thinner than a pixel at hero size) is off by default (`photonRing`). Stars are fully lensed near
the rim, less so further out (`starLens`), because seen this close the whole frame lies inside the Einstein
radius.
