Skip to content
animorio
IT
Back to the library

The profile in the black hole

WebGL, Transitions, ImagesWebGL, Canvas, GSAPAdvancedCompressed weight: Demo 13 KBGSAP + SplitText + CustomEase 35 KBFreeDownload the zip

A button opens a black hole in the middle of the page: the disc grows with a wobbling rim, the strip of pictures around it bends like light around a heavy star, and the profile’s lines rise inside it.

The code

A folder that runs on its own: index.html, style.css, script.js and the libraries from a CDN. The React and Vue tabs hold the same demo as a ready component, which switches its animations off when it leaves the page.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>The profile in the black hole</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/mona-sans@5.3.0/standard.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/instrument-serif@5.3.0/index.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage" data-stage>
    <!-- the strip of pictures and the floor are drawn here; the hole is a second pass over the finished picture -->
    <canvas class="stage__canvas" data-canvas aria-hidden="true"></canvas>

    <header class="bar">
      <p class="brand">Darksite</p>
      <button class="toggle" type="button" data-toggle data-autoclic aria-expanded="false" aria-controls="profile">Profile</button>
    </header>

    <!-- the series: the script paints these names on the pictures, and they stay here for whoever cannot see them -->
    <ul class="series" data-series>
      <li><h2>Star trails</h2><p>Four hours facing north</p></li>
      <li><h2>Perseids</h2><p>One frame, forty minutes</p></li>
      <li><h2>Saturn, low</h2><p>Rings open, the week before harvest</p></li>
      <li><h2>The long eclipse</h2><p>Two minutes of totality</p></li>
      <li><h2>Moonrise on salt</h2><p>A flat that holds the light</p></li>
      <li><h2>Comet season</h2><p>Twenty cold mornings</p></li>
      <li><h2>Blue hour</h2><p>The quarter hour nobody keeps</p></li>
    </ul>

    <!-- the profile sits where the hole opens: the script reads this box to know the hole's centre and size -->
    <section class="profile" id="profile" data-profile aria-label="Profile" inert>
      <div class="profile__inside" data-inside>
        <p class="profile__text" data-lines>Darksite is one photographer, two tripods and a thermos, out wherever the street lights stop. Every picture is a single long exposure, never stacked: what the sky did that night, on one frame.</p>
        <p class="profile__facts" data-lines>Seven series, three books, one very cold tripod</p>
        <div class="profile__row">
          <ul class="profile__links" data-row>
            <li><a href="#">Prints</a></li>
            <li><a href="#">Books</a></li>
            <li><a href="#">Instagram</a></li>
            <li><a href="#">Email</a></li>
          </ul>
        </div>
      </div>
    </section>

    <footer class="foot">
      <p>Night-sky photography</p>
      <p data-hint>Drag the strip</p>
    </footer>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/CustomEase.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script>
  <script src="script.js"></script>
</body>
</html>

Playground

Change values, colours and text with the controls and watch the animation respond live. Or write in the code yourself. Then download the folder with your changes.

95 values to change

Or ask Claude Code for it

With the Animorio connector, Claude Code takes it from here, guide included, and fits it into your project. Paste this request:

Take “The profile in the black hole” from Animorio and fit it into my site

What it does

The portfolio of an invented night-sky photography studio: seven series in a strip that drifts slowly by itself over a floor of squares, and that you can drag. Top right there is “Profile”. Press it and a black disc opens in the middle: it starts from a point, grows in under a second with a lobed rim that wobbles, and once open it is a circle that barely breathes. Around the disc the page is not covered but bent: the photo that was behind it wraps into a ring, the lines of the floor curve around it, and whatever is caught in the ring slowly turns. At the rim red and blue part by a hair. Inside the disc the profile’s lines rise one after the other, then a line of facts and four links. “Close”, the Esc key or a click outside the disc shut it: the disc squashes, shrinks and takes the text with it, getting smaller along with it.

How it works

The page into a texture

The strip and the floor are first drawn into a framebuffer as big as the canvas. Every picture is a rectangle given in pixels, its round corners worked out in the fragment shader. The floor is a single full-screen rectangle: for every pixel below the horizon the shader works out how far away that point of floor is (the eye’s height times the eye’s distance from the screen, divided by the pixels below the horizon), and the lines fall where the position is a multiple of a square’s side. While the hole is shut the page goes straight to the screen: the second pass only runs when there is a hole.

The lens

The second pass reads the texture back through one line of physics. For every pixel at distance t from the hole’s centre (in page widths) it shows what the page holds at distance t − E² / t, in the same direction; E is the hole’s radius times LENS, 1.5. Far away nothing changes. Near the rim the result goes to zero: you see the page’s middle, wrapped all the way round. Closer in it turns negative: the sample crosses to the other side and the far side appears, mirrored.

The leash

Left alone, the bending falls off slowly and warps the page right out to the corners. It is multiplied by 1 − smoothstep(rim, rim + REACH, t): full at the rim, nothing 0.09 page widths further out. That is what makes the hole an object sitting on the page and not a filter over all of it.

A living rim

The rim is the radius plus 0.6 · sin(3a + 0.7t) + 0.4 · sin(5a − 0.45t), a being the angle: three lobes against five, turning opposite ways, never repeating. All of it times WOBBLE and times sin(π · openness), so shut and open the rim is a circle. Once open the radius breathes by 1.5%. The direction the page is read in is turned by an angle equal to the bending times ORBIT times the time: whatever is caught near the rim goes round it. Red and blue are read with a ring 0.4% bigger and smaller. While closing, space is multiplied by (1 + s, 1 − s) before distances are measured, and the whole hole squashes at once.

Timing and text

Opening takes 0.85 seconds and closing 0.65, on a three-part curve (CustomEase) that covers 88% of the way in a third of the time and then settles for a long while. The text is HTML over the canvas: SplitText cuts the paragraph where the browser breaks it and puts every line in a mask; the lines rise from 115% in 1.1 seconds of expo.out, 0.075 seconds apart, starting after 0.2. While closing, the block shrinks with the hole and fades before it is gone. The hole’s centre and radius are read from the profile’s HTML box: move or resize that in the CSS and the hole follows.

Values to change

NameFileDefaultWhat it changes
OPEN, CLOSEscript.js0.85, 0.65The seconds the hole takes to open and to close.
LENSscript.js1.5How hard the page bends: the ring’s radius, as a multiple of the hole’s. At 1 it hardly bends at all.
REACHscript.js0.09How far past the rim the bending reaches, in page widths.
ORBITscript.js0.3How fast what is caught near the rim turns about the hole, in radians per second.
WOBBLE, BREATHscript.js0.05, 0.015How far the rim leaves the circle while it opens and closes (page widths), and how much it breathes once open (share of the radius).
SPLITscript.js0.004How far red and blue part at the rim.
SQUASHscript.js0.1How squat the hole gets while it closes.
LINESscript.js0.2, 1.1, 0.075The text in the hole: the wait, a line’s duration and the gap between lines, in seconds.
DRIFTscript.js22The pixels per second the strip drifts by itself.
STRIPscript.js0.44, 12, 18The pictures: height (share of the page), gap and corner radius in pixels.
FLOORscript.js0.17, 0.125The floor: how bright its lines are and how wide a square is, in page widths.
SERIESscript.js7The seven pictures: subject, proportions and colours. To use photos, pass images to texImage2D instead of the canvases.
--holestyle.cssmin(40vw, 62vh)The hole’s diameter: it is the size of the profile’s box, and the script reads it from there.

Try them in the Playground

When to use it

To open, over a page full of pictures, a panel that has to be noticed: a profile, contacts, a menu. It pays off when there is something behind it to bend, photos or large type, on a dark background. On a light page of plain text you would see little more than a black circle.

On phones

The hole is almost as wide as the screen (88% of its width), the strip moves with a finger and the text stays at 13 px. It shuts with the button or with a tap outside the disc.

With reduced motion

No lens: the panel is a black disc that appears in a quarter of a second with the text already in place, and the strip does not drift by itself (it can still be dragged).

Pitfalls

  • The turning angle grows with time, and grows differently from one point of the rim to the next: after a minute two neighbouring pixels read points far apart and the ring tears into coloured streaks. The turning’s clock stops at 15 seconds.
  • sin(π · openness) silences the lobes at 0 and at 1, but past 1 it turns negative: with a curve that overshoots the rim would turn inside out just while you are looking at it. Openness is clamped between 0 and 1 before the sine.
  • The antialiased edge is a pixel and a half wide even when the radius is zero: unchecked, a shut hole would leave a black speck in the middle. Below 0.0005 of openness the second pass does not run.
  • Squashing the finished picture would flatten the ring and leave the disc round. The squash is applied to the space every distance is measured in, and undone when going back to read the page.
  • The names on the pictures are painted on a canvas with the page’s font: if the font has not arrived yet the canvas uses the fallback without a word. Wait for document.fonts.load before painting.

Libraries

GSAP 3.15.0, CustomEase (GSAP) 3.15.0, SplitText (GSAP) 3.15.0, Mona Sans (font variabile) 5.3.0, Instrument Serif 5.3.0.

Compressed with gzip, the three demo files weigh 13 KB; the libraries: GSAP + SplitText + CustomEase 35 KB.

Testing

Tested on 30 September 2026

  • Desktop: Done
  • Phone: Done
  • Reduced motion: Done
  • Safari on iPhone: Done

License

Use it in your own projects and in your clients’ projects. Do not resell it or redistribute it as is. GSAP stays under its own Standard license. Full license.

All animations