Skip to content
animorio
IT
All components

The trapdoor table

Tables, Physics, SVG, Transitions, 3DGSAPAdvancedCompressed weight: Demo 23 KBGSAP 28 KBFreeDownload the zip

A studio's invoices in a real table: the rows picked are held by one elastic shape that grows out of the box ticked, stretches over the next row, pours down the whole table and tears in two when a row in the middle is let go. The footer rolls out into the bar of actions; deleted rows fall through a trapdoor in the table while the others slide over it, and Undo swings them back up.

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 trapdoor table</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/archivo@5.3.0/standard.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- the trapdoor table: a studio's invoices. The rows picked are held by one elastic shape that grows out of the
         box ticked; the footer rolls out into a bar of actions; deleted rows fall through a trapdoor in the table
         while the rows below slide over the hole, and Undo swings them back up -->
    <section class="ledger" aria-labelledby="ledger-title" data-ledger data-autoclic>
      <header class="ledger__head">
        <h2 class="ledger__title" id="ledger-title">Invoices</h2>
        <p class="ledger__note">September and October</p>
      </header>

      <div class="frame" data-frame>
        <div class="pit" aria-hidden="true" data-pit></div>
        <table class="table" aria-labelledby="ledger-title" data-table>
          <thead>
            <tr>
              <th class="pick" scope="col">
                <label class="check" data-all-check>
                  <input type="checkbox" data-all>
                  <span class="check__box" aria-hidden="true"><svg viewBox="0 0 24 24"><path class="check__tick" pathLength="1" d="M6.2 12.4l3.9 3.9 7.7-8.2"/><path class="check__dash" pathLength="1" d="M7.5 12h9"/></svg></span>
                  <span class="sr">Select all invoices</span>
                </label>
              </th>
              <th class="client" scope="col">Client</th>
              <th class="col-inv" scope="col">Invoice</th>
              <th class="col-due" scope="col">Due</th>
              <th class="num" scope="col">Amount</th>
              <th class="state-cell" scope="col">Status</th>
              <th class="more" scope="col"><span class="sr">Details</span></th>
            </tr>
          </thead>
          <tbody data-rows></tbody>
        </table>
        <svg class="band" aria-hidden="true" data-band>
          <defs>
            <linearGradient id="ledger-sweep" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="1" y2="0" data-sweep>
              <stop offset="0" class="band__stop"/>
              <stop offset="0.5" class="band__stop band__stop--deep"/>
              <stop offset="1" class="band__stop"/>
            </linearGradient>
          </defs>
          <path class="band__shape" data-band-path/>
        </svg>
      </div>

      <div class="foot" data-foot>
        <p class="sum" data-sum>
          <span><span class="roll" data-sum-count>6</span> invoices</span>
          <span class="sum__due"><span class="roll" data-sum-due>€0.00</span> outstanding</span>
        </p>
        <div class="bar" data-bar>
          <span class="bar__bg" aria-hidden="true" data-bar-bg></span>
          <div class="bar__set" role="group" aria-label="Selected invoices" data-set="actions">
            <button class="bar__icon" type="button" data-clear>
              <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7 7 17"/></svg>
              <span class="sr">Clear selection</span>
            </button>
            <p class="bar__count"><span class="roll" data-picked>0</span> selected <span class="bar__sum roll" data-picked-sum>€0.00</span></p>
            <span class="bar__rule" aria-hidden="true"></span>
            <button class="bar__btn" type="button" data-act="paid">
              <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.6l4.3 4.3L19 7.3"/></svg>
              <span class="bar__label">Mark as paid</span>
            </button>
            <button class="bar__btn bar__btn--export" type="button" data-act="export">
              <span class="bar__glyph" aria-hidden="true">
                <svg class="bar__glyph-a" viewBox="0 0 24 24"><path d="M12 4.5v10M7.6 10.4 12 14.8l4.4-4.4M5 15.5v3.5h14v-3.5"/></svg>
                <svg class="bar__glyph-b" viewBox="0 0 24 24"><path d="M5 12.6l4.3 4.3L19 7.3"/></svg>
              </span>
              <span class="bar__label roll" data-export-label>Export</span>
            </button>
            <button class="bar__btn bar__btn--danger" type="button" data-act="delete">
              <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 7h15M9.5 4.5h5M6.6 7l.9 12.1a1.2 1.2 0 0 0 1.2 1.1h6.6a1.2 1.2 0 0 0 1.2-1.1L17.4 7M10.2 10.8v5.6M13.8 10.8v5.6"/></svg>
              <span class="bar__label">Delete</span>
            </button>
          </div>
          <div class="bar__set bar__set--toast" data-set="toast">
            <p class="bar__msg" data-msg></p>
            <button class="bar__btn bar__btn--undo" type="button" data-undo>
              <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9.2 5.6 5.4 9.4l3.8 3.8M5.8 9.4h8.4a4.6 4.6 0 0 1 0 9.2h-3"/></svg>
              <span class="bar__label">Undo</span>
            </button>
            <span class="bar__time" aria-hidden="true" data-time></span>
          </div>
        </div>
      </div>
      <p class="sr" aria-live="polite" data-live></p>
    </section>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.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.

118 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 trapdoor table” from Animorio and fit it into my site

What it does

On a white background the title Invoices, wide and heavy, with September and October on the right; under it, a table with the columns Client, Invoice, Due, Amount and Status and six invoices of a studio (Halden Moss, Mirabel Studio, Northfold, Ottoline Press, Okra Kitchen, Tessaline), the amount in euros set to the right and the state told by the word, its colour and a small icon: Paid in black with a green tick, Overdue dark orange with a triangle, Sent slate grey with an envelope, Draft grey with a pencil. At the bottom, 6 invoices and how much is still to come in. Ticking a box, the box turns green and its tick draws itself, and out of its little square comes a light green shape that spreads over the whole row with a small bounce; ticking the row below, the shape stretches down to it and the two rows become one block with round corners, while a row further away gets a block of its own. Meanwhile the footer rolls out from the left into a dark bar: 3 selected with the sum of the invoices picked, Mark as paid, Export and Delete, coming in as its edge goes by. The box in the head picks every row, and the shape runs down the table as if poured; letting a row in the middle go, the shape tears there and the two halves spring back each to its own side; dragging down the boxes paints many rows in one stroke, and Shift picks a range. Mark as paid rolls the states over to Paid one row after another, the tick drawing itself; Export runs a light along the rows picked and the button says Exported. Delete opens a trapdoor: the rows picked, each run like a door hinged at its top edge, fall into the table, darkening and shortening in perspective, the dark shows underneath, and the rows below slide up all together to close the holes. The bar becomes 3 invoices deleted with Undo and a line running out; Undo opens the holes again, the doors swing back up, slap shut with a little bounce, and the rows come back picked as they were. Clicking a row opens its details under it: the project, the person, the lines with their amounts under the Amount column and what has happened under Status.

How it works

One shape

Every row has a piece of the shape: its top and bottom edges as shares of the row's height, its two sides in pixels in from the table's sides, all four on damped springs (x'' = −k(x − goal) − c·x', in steps of 1/240 of a second, the frame's step limited to 1/30). At every frame the pieces that touch become one outline, down the right side and back up the left, with every corner rounded, the inner ones too, as much as the sides next to it allow. The SVG path lies over the table in mix-blend-mode: multiply, so the rows stay solid and the shape tints them under the words. Two blocks about to touch, or just parted, have square corners that round off as the gap between them opens: joining and parting never jump.

Where it comes from, where it goes back

A new piece starts from the little square of its box; if the row above is picked already it starts from that row's edge and stays fastened to it while it grows, so the shape stretches without coming apart, and picking every row each one starts from the edge of the one before a moment later, as if poured. Every row also has a twin piece: between two blocks the row picked reaches out to both of them, and let go in the middle of a block it splits into its two halves, which go back each to its own neighbour. Going back into its box a piece first slides in sideways and then shrinks, so it never becomes a thin line over the name.

The footer that becomes a bar

The bar lives in the table's footer: the summary goes and a dark square rolls out to the right as wide as what it says, with a little overshoot; its parts are cut by the edge going by (clip-path) and come in one after the other as it passes them. The number of rows picked rolls over, the sum counts, and Mark as paid dims when none of the rows picked is waiting to be paid. The actions that can be undone turn the bar into the message with Undo and a line running out in UNDO_FOR seconds; the focus always moves to the right button before the old one goes.

The trapdoor

Delete takes every run of rows next to each other and makes it a door: a copy of the rows, with the same column widths, under the table, in the same place. The real rows leave the DOM at once and the ones below are taken back to where they were with a transform (FLIP). The door turns round its top edge with perspective and rotateX(−φ), pulled by gravity, φ'' = g·cos φ, with g worked out so that it is edge-on after DROP seconds, and it darkens with the sine of the angle. When the door is at 35 degrees the rows below set off all together on the same spring, each as far as it has to go: so every hole closes in proportion and the table never tears where there was no hole. The dark between the rows is drawn again at every frame in SVG, a rectangle for every gap, darker under the edge above, and nothing shows below the footer.

Undo

The rows go back to their places in the DOM but hidden; the ones below start from where they were and move down to make room, opening the dark holes, and in every hole its door swings back up from edge-on with a steady lift, slaps flat and bounces once (restitution 0.26). When it stops, the real rows take its place with their shape already whole, picked as they were, and the bar goes back to the actions. Undoing Mark as paid rolls the states back and brings the selection back.

A real table, keys, phone

It is a table with its heads, the boxes are real inputs with a hidden label (Select INV-2042, Mirabel Studio), the box in the head has the indeterminate state, the details are a row with aria-controls and aria-expanded, and an aria-live region says how many invoices are picked, deleted or marked. Esc clears the selection. Dragging over the boxes holds the pointer only once it reaches another row, and the click that ends the stroke is thrown away. On a phone Invoice and Due move under the name, the type narrows with the width axis (90%) and the details' cell spans exactly the columns that are left. A click from a script (the library's preview) picks, deletes, undoes, picks all, marks as paid, opens a row and exports, one thing per click.

Values to change

NameFileDefaultWhat it changes
INVOICESscript.js[['Halden Moss', 'INV-2041', 'Sep 30', 2400, 'paid'], …]The invoices: client, number, due date, amount in euros and state (paid, overdue, sent or draft).
DETAILSscript.js[['Brand guidelines, second round', 'Ines Halden', 'Art direction, 3 days', 1500, 'Type licences', 'Sent Sep 16|Opened Sep 17|Paid Sep 29'], …]Each invoice's details when its row opens: project, person, a first line with its amount, a second line (the rest of the amount) and what has happened, split by |.
SPRINGscript.js[360, 0.56]The springs of the selection's shape: how stiff, and how damped, from 0 (wobbles a lot) to 1 (does not wobble).
RADIUSscript.js12The radius of the shape's corners, in pixels.
DROPscript.js0.38The seconds deleted rows take to fall through the trapdoor.
CLOSEscript.js[320, 0.78]The springs of the rows closing the holes: how stiff, and how damped.
CHAINscript.js0.05The seconds between one trapdoor and the next, when rows go from different places.
UNDO_FORscript.js6The seconds Undo stays in the bar.

Try them in the Playground

When to use it

For a studio's invoices, a shop's orders, the members of a team, a newsletter's subscribers, the files of an archive: any back office where rows are picked to do something to all of them at once.

On phones

At 390 pixels the Invoice and Due columns go and the number and due date move under the client's name; the type narrows to 90% with the width axis, the rows are 58 pixels tall and the boxes are 44 pixel targets. The bar keeps its icons (the words stay for screen readers). Dragging a finger down the boxes picks many rows; anywhere else the finger scrolls the page.

With reduced motion

Nothing grows or falls: each picked row's shape fades in and out already in place, deleted rows fade away and the others are at once in their places, the ones brought back fade in, the bar and the summary fade into each other and the details open already open.

Pitfalls

  • rotateX with a positive angle brings the row's bottom edge towards the eye: the door seemed to rise off the table instead of falling into it. A door falling into the table turns by −φ.
  • Rows closing the holes one after the other open the table where there was no hole, and with the holes filled with a flat dark they looked like grey blocks: they all set off together on the same spring, so every hole closes in proportion, and the dark is drawn only in the real gaps, at every frame.
  • Holding the pointer on pointerdown sends the click to the cell and not to the box, which stopped ticking: it is held only when the finger or the mouse reaches another row, and the click that ends the stroke is thrown away.
  • On a phone the details' cell with colspan 7, with only five columns showing, makes two ghost columns that take half of the Client column: the span is the number of columns showing, worked out again whenever the size changes.
  • A piece growing out of a row still growing came apart into separate pills, and a piece going back into its box became a thin line over the name, like a strike-through: the first stays fastened to its neighbour's edge, the second slides in sideways before it shrinks.
  • Hubot Sans's tabular zero has a slash (Sep 3θ, €2,4θθ.θθ): Archivo is used here, with tabular figures, a plain zero and the width axis for the phone.
  • A CSS transition on the opacity of the bar's buttons delayed GSAP's tweens: the dimmed button fades its children, the button's own opacity belongs to the script.

Libraries

GSAP 3.15.0, Archivo (font variabile) 5.3.0.

Compressed with gzip, the three demo files weigh 23 KB; the libraries: GSAP 28 KB.

Testing

Tested on 6 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 components