Skip to content
animorio
IT
Back to the library

The nested-frame gallery

Images, TransitionsGSAP, CanvasAdvancedCompressed weight: Demo 8.9 KBGSAP + CustomEase 31 KBFreeDownload the zip

In the minimal style: five abstract pictures one inside the other. Every picture has a thin frame in its middle holding the next one; on a click the camera breathes back, then dives into the frame with a measured exponential zoom, goes three pixels past and settles, and the picture inside becomes the page while the caption rolls letter by letter. The last one holds the first again, so it never ends.

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>Inward</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/archivo@5.3.0/wdth.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <!-- five pictures, one inside the other: each one holds the next in a thin frame in its middle -->
  <main class="room" data-room data-autoclic tabindex="0" role="region" aria-roledescription="gallery" aria-label="Inward, a gallery of pictures one inside the other. Click, tap or press the right arrow to step into the frame; the left arrow steps back out.">
    <canvas class="stage" data-stage aria-hidden="true"></canvas>

    <header class="top">
      <p class="mark" data-mark aria-hidden="true">Inward</p>
      <p class="sub" data-sub>Five rooms, one inside the other</p>
    </header>

    <div class="caption" aria-hidden="true">
      <p class="caption__title"><span class="slot" data-title></span></p>
      <p class="caption__note"><span class="slot" data-note></span></p>
    </div>

    <p class="hint" data-hint aria-hidden="true">Click to step inside</p>
    <p class="sr" data-live aria-live="polite"></p>
  </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="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.

23 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 nested-frame gallery” from Animorio and fit it into my site

What it does

A paper-coloured page with the name of a made-up gallery, Inward, at the top left and a small line at the right. In the middle a big stone-coloured disc sets behind a thin horizon line, with a vermilion dash on the water: it is the first picture, Low sun. On the disc a thin black line draws itself all round a rectangle, then inside it a window opens from a horizontal line on a spring: in it is the second picture, a paper-coloured arched door in a black wall, which has its own frame holding the third, and so on, smaller and smaller. Under the frame, as small as a museum label, is the title of the picture it holds; at the bottom left the page's title and a note rise letter by letter. After a couple of seconds, or on a click, the camera breathes back (for a moment the picture that holds this one shows at the edges) and then dives into the frame: the zoom grows by the same factor every instant, so the speed feels constant, eased only at the start and the end; the frame's line leaves the screen, the label grows and fades before it reaches the bottom, the camera goes three pixels past and comes back with a small elastic. Now the arch is the page, the words have turned paper-coloured to read on the black and the caption has rolled to the new title. The five pictures are the low sun, the arched door with its vermilion sill, thin lines spreading out away from the window with a single vermilion one, a grid drawn on the frame's own size with one vermilion square, and a thin ring on black with a vermilion sun in eclipse; the last one holds the first again. With a mouse over the frame the camera leans in a little, as if to look closer, and the next dive waits. The arrow keys go in and out; on a phone a tap goes in and a swipe to the right comes back out.

How it works

One map, one number

Every picture is drawn in the same box as big as the page, and its window is that box shrunk by SCALE and moved to (x0, y0): a point of the next picture lands at (x0, y0) plus SCALE times the point. That map has a single point that never moves, F = (x0, y0) / (1 − SCALE). Zooming by 1/SCALE about F brings the window exactly onto the page, and every picture deeper in comes along. So the camera is one number, d: 0 is the first picture as the page, 1 the second, 0.5 halfway in; the picture k levels deeper is drawn scaled by SCALE to the (k − d) about F.

Drawing from the biggest to the smallest

On every frame the GSAP ticker draws on a canvas the page, then the picture in its window, then the one inside it, until the next would be smaller than two pixels: each one covers the window of the one before. The starting level is the whole part of d, so d can grow for ever and the loop comes back to the first picture by itself (the remainder after dividing by the number of pictures). With d just under a whole number (the breath back) the picture that holds the page is drawn too, and shows at the edges.

The exponential zoom

The tween moves d, not the scale: the scale is SCALE to the −d, so equal steps of d always give the same zoom factor, and the eye feels a constant speed. The ZOOM_EASE curve (a CustomEase) only eases the start and the end. Before it there is a breath back of 0.014 in a quarter of a second; after it, d goes past the target by just enough to make OVERSHOOT pixels at the edge of the page (the log of 1 + OVERSHOOT / half the width, over the log of 1/SCALE) and comes back with elastic.out.

Hairlines at every depth

The shapes are drawn at the scale of their level, the lines are not: their width on screen is the scale to the 0.4, between 0.45 and 1.4 pixels, so a line stays a hair both on the page and three levels deeper, and it changes smoothly during the zoom (no jump when the picture becomes the page). The frame is drawn in screen pixels, on the half pixel when the page is at rest, so it is sharp. The label under the frame is written at the scale of its level and is skipped below two pixels of type size; during the dive it fades before it reaches the caption.

The opening, the caption and the colours

At the opening the frame's line draws itself with setLineDash, then the first window opens from a line on a real spring: everything deeper stays cut inside that rectangle. The caption sits in slots with hidden overflow: the old letters leave upwards while the camera is in the frame, the new ones rise as it lands, with the typeface's wdth axis going from 72 to 100 on an elastic. When the new ground covers most of the page, GSAP takes the --fg and --muted variables to the colour that reads on it.

Values to change

NameFileDefaultWhat it changes
PICTURESscript.js[{ title: 'Low sun', note: 'Stone and vermilion on paper' }, …]The pictures, in order: the title (also the label under the frame that holds it) and the small note under the title. There are five drawings, repeated if there are more entries.
SCALEscript.js0.3How big the frame is in the page: 0.3 means three tenths. The smaller it is, the longer the dive and the more pictures you see one inside the other.
ZOOMscript.js2.1How many seconds the camera takes to go through the frame.
ZOOM_EASEscript.jsCustomEase.create('inward', '0.62, 0, 0.24, 1')The curve of the dive: it eases the start and the end of a zoom that otherwise runs at a constant speed.
OVERSHOOTscript.js3How many pixels the camera goes past before it settles (0 = it arrives smoothly).
HOLDscript.js2.4How many seconds each picture stays before the next dive.
MATscript.js10The gap between the window and its thin line, in pixels.
AUTOPLAYscript.jstrueWhether the camera goes in by itself. With false it goes in only with a click, a tap or the arrow keys.

Try them in the Playground

When to use it

For a gallery or a portfolio that should feel like a path, not a grid: an exhibition page, an artist's or a photographer's site, the presentation of a series, the move between the chapters of a project. It works with pictures that have a free centre, or with illustrations made on purpose to hold the next one.

On phones

The same animation with the sizes adapted: the page is upright, so the frames are too, a little bigger and a little higher to leave room for the caption. A tap goes in, a swipe to the right comes back out, to the left goes in.

With reduced motion

No zoom: everything fades in, and at every step the page and the caption fade out and back in with the next picture, with no breath, elastic or rolling letters.

Pitfalls

  • The zoom has to be about the map's fixed point, F = (x0, y0) / (1 − SCALE), not about the middle of the frame: the frame is not centred (the page leaves room for the caption) and about its middle the deeper levels would slide sideways and the loop would not come back the same. About F every level is the same drawing, only bigger, and moving d (the exponent) rather than the scale makes the zoom feel exponential.
  • The tide lines and the grid square, drawn over the whole page, ran under the name at the top and under the caption (on a phone a line cut through the note) and the vermilion square touched the label: the lines stop between 12% and 84% of the height and the square sits to the right of the frame, away from the label.
  • The breath back at the start of the dive shows at the edges the picture that holds the page: at 0.022 it made dark bands twenty pixels wide that looked like a glitch; at 0.014 it is a thin strip that reads as a breath.
  • Growing, the label under the frame reached the bottom left just as the caption was changing, and for a moment there were two titles on top of each other: the label fades once it is past 1.35 times its size.
  • The text colour lives in a variable on the container, but body had already computed it and handed it to its children resolved: the words stayed black on the black ground. The colour has to be declared on the container itself (color: var(--fg)).
  • The first frame, drawn before the opening timeline, already showed everything and then vanished: the starting states are set with gsap.set before the first drawing.

Libraries

GSAP 3.15.0, Archivo (font variabile) 5.3.0.

Compressed with gzip, the three demo files weigh 8.9 KB; the libraries: GSAP + CustomEase 31 KB.

Testing

Tested on 15 October 2026

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

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