Skip to content
animorio
IT
All components

The column search

Search, Text, Transitions, PhysicsGSAP, CSS onlyAdvancedCompressed weight: Demo 15 KBGSAP 28 KBFreeDownload the zip

The search of a cooking site where the words typed sit in the middle of the field and every recipe suggested slides sideways until the same letters sit right under them: the letters found make a column, marked by a highlighter stroke running down from the field through the list. New recipes unroll out of their letters, the ones that stop matching roll back into them, and the one taken rises into the field, landing on the letters typed.

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 column search</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/alegreya@5.3.0/wght.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/alegreya@5.3.0/wght-italic.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- the search of a cooking site. The words typed sit in the middle of the field, and every recipe suggested
         slides sideways until the same letters sit right under them: the letters found make a column, marked down
         the list like a highlighter. A new recipe unrolls from its letters, a chosen one rises into the field.
         The rows are made by the script from the list at the top of script.js -->
    <div class="seek" data-seek data-autoclic>
      <form class="seek__form" role="search" action="#" novalidate data-form>
        <div class="seek__field" data-field>
          <span class="seek__swipe" aria-hidden="true" data-swipe></span>
          <svg class="seek__lens" viewBox="0 0 24 24" aria-hidden="true"><path d="M10.5 4.5a6 6 0 1 0 0 12a6 6 0 1 0 0-12zM15 15l4.6 4.6"/></svg>
          <input class="seek__input" id="seek-input" type="text" inputmode="search" enterkeyhint="search" role="combobox" aria-label="Search recipes" aria-autocomplete="list" aria-expanded="false" aria-controls="seek-list" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="Search recipes" data-input>
          <span class="seek__caret" aria-hidden="true" data-caret></span>
          <button class="seek__clear" type="button" aria-label="Clear the search" tabindex="-1" data-clear>
            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7.5 7.5l9 9M16.5 7.5l-9 9"/></svg>
          </button>
          <button class="seek__go" type="submit" aria-label="Search" aria-disabled="true" data-go>
            <span class="seek__arrow" aria-hidden="true" data-arrow><svg viewBox="0 0 24 24"><path d="M5 12h13M12.5 6.5 18 12l-5.5 5.5"/></svg></span>
          </button>
        </div>

        <div class="seek__panel" data-panel>
          <div class="seek__rows" data-rows>
            <span class="seek__band" aria-hidden="true" data-band></span>
            <span class="seek__beam" aria-hidden="true" data-beam></span>
            <ul class="seek__list" id="seek-list" role="listbox" aria-label="Recipes" data-list></ul>
          </div>
          <div class="seek__none" data-none hidden>
            <p class="none__what" data-none-what></p>
            <p class="none__tip" data-none-tip></p>
          </div>
        </div>

        <p class="seek__note" data-note><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11M10.5 5.5 15 10l-4.5 4.5"/></svg><span data-note-words></span></p>
        <p class="sr" role="status" aria-live="polite" data-said></p>
      </form>
      <div class="seek__flight" aria-hidden="true" data-flight></div>
      <!-- the highlighter's edge: not ruled, but a little uneven, like a real marker's -->
      <svg class="seek__defs" aria-hidden="true" focusable="false">
        <filter id="seek-marker" x="-10%" y="-4%" width="120%" height="108%">
          <feTurbulence type="fractalNoise" baseFrequency="0.03 0.07" numOctaves="2" seed="7" result="grain"/>
          <feDisplacementMap in="SourceGraphic" in2="grain" scale="2.4" xChannelSelector="R" yChannelSelector="G"/>
        </filter>
      </svg>
    </div>
  </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.

60 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 column search” from Animorio and fit it into my site

What it does

On a white background a white field with rounded corners, a lens on the left, the italic words Search recipes in the middle and on the right a dark square with an arrow, dimmed while the field is empty. Typing, the letters sit in the middle of the field on a stroke of yellow highlighter, the lens spins round into a small cross that empties the field, and the square lights up. At the first letter the list opens under the field and a highlighter stroke runs down from the top right under the letters typed, its edges a little uneven like a real marker’s; along it the recipes come in one after the other, first only their matching letters, bold on the yellow, then the rest of the name unrolling to the left and to the right. Each recipe is moved sideways just enough for its letters to sit exactly under the ones in the field, so they read as a column: Saffron risotto, Lemon ricotta pancakes and Apricot rice pudding have their ri one under the other. The names that start that way come first, then those with a word that starts that way, then those with the letters inside a word. With every letter added the recipes that stop matching roll back into their letters and fade, the ones that stay wait a moment and slide on a spring to their new place, and sideways to stay on the column, and the new ones unroll where there is room; the yellow stroke widens or narrows with the letters and the list grows or shrinks. The arrows or the pointer put a grey band under a recipe, which turns black; Enter, a click or a tap takes it: the others fade, the list goes back up into the field and the name rises straight up until its letters land on the ones typed, then slides into the middle of the field while the highlighter widens under the whole name and fades, and Opening with the name shows under the field. Enter with no recipe picked, or the square with the arrow, searches: the arrow leaves to the right and comes back from the left, the list closes and the line under the field says how many recipes there are, for example 5 recipes for “pe”. If nothing matches, the highlighter in the field turns grey, the yellow stroke goes back up and the list says No recipes for with the letters typed, and some advice: Did you mean risotto? when a word of the recipes is close to the one typed, otherwise Try an ingredient, like lemon, saffron or pear. The word offered, tapped, rises into the field the same way and the search starts again with it. Esc closes the list and, once it is closed, empties the field.

How it works

The words in the middle of the field

The input is real: role combobox, aria-expanded, aria-controls pointing at the list (role listbox) and aria-activedescendant on the recipe picked, which has aria-selected. It has text-align center and the same room on both sides, so the words typed sit in the middle; the small cross that empties it takes the lens’s place, on the left, so as not to steal room. The width of the words is measured with a hidden copy in the input’s own font (getBoundingClientRect, with the decimals); when they are wider than the field they start from its edge and the scroll (scrollLeft) is taken off.

The search

search() compares the letters regardless of capitals and accents: every letter becomes a single letter (é becomes e), so a place in the plain name is the same place in the real one. For each recipe it keeps the best place, the start of the name, then the start of a word, then inside a word, and for a tie the order of RECIPES; it shows SHOWN of them, with the letters found in a mark. When nothing matches, each word typed that is in no name is compared with the words of the recipes using the Levenshtein distance (one letter for short words, two from six letters up).

The column

For each row the script measures where the mark sits inside the name (its getBoundingClientRect less the name’s) and moves the row sideways by the middle of the words typed less the middle of the mark: the letters found land exactly under the ones in the field, to a hundredth of a pixel. The highlighter is a single element, as wide as the widest mark, with an SVG filter (feTurbulence and feDisplacementMap) that makes its edge uneven like a marker’s, and mix-blend-mode multiply, so over the grey band it darkens like real ink.

Rows on springs that never cross

Each row’s position, sideways and up and down, rides on two springs (SPRING: stiffness and damping) integrated in steps of 1/240 of a second in GSAP’s ticker, which stops drawing once everything is still; typing fast the springs only change their target, without starting over. Between the old list and the new one the script finds the longest increasing subsequence of the rows that stay: those slide in their order and never cross, the others leave and come back in their new place. The ones that stay wait 0.08 seconds for the ones leaving to fade, and the new rows come in once the others have almost stopped.

Unrolling out of the letters

A new row comes in with a clip-path inset() that at first shows only the mark and opens to the whole name in UNROLL seconds (power3.out): it seems to come out of its letters, to the left and to the right. A row leaving does the opposite in 0.16 seconds. At the opening the rows come one after the other while the yellow stroke draws itself from the top (scaleY); when nothing matches it goes back up into the field.

Taking a recipe

The name taken is copied into a layer above everything, at the exact spot where it is, and rises in half of LIFT seconds: its letters found are already under the ones typed, so they land on them without moving sideways. Then it slides into the middle of the field, the mark thins from 700 to 420 in weight (the font is variable) and the highlighter, measured again on every frame, follows it, widening from the letters found to the whole name; on arrival the input takes the name and the copy goes. A click from a script (detail 0, the library’s preview) types without taking the focus, with a drawn caret, a step per click: ri, then s and the arrows, the recipe taken, a typo and the close word, pe and the search.

Values to change

NameFileDefaultWhat it changes
SHOWNscript.js6How many recipes the list shows at most.
ROWscript.js48The height of a row, in pixels.
SPRINGscript.js[420, 30]The spring that keeps the rows on the column: stiffness, and damping.
UNROLLscript.js0.45The seconds a new recipe takes to unroll out of its letters.
LIFTscript.js0.7The seconds the recipe taken takes to rise into the field and settle in the middle.
TRIESscript.js['lemon', 'saffron', 'pear']The ingredients offered when nothing matches and no word is close.
RECIPESscript.js['Saffron risotto', 'Lemon ricotta pancakes', …]The recipes to search, the most cooked first.

Try them in the Playground

When to use it

For the search of a recipe site, a shop, a bookshop, a product’s documentation: wherever the names to find are short and it helps to see at once why a result was found, even when the letters searched for sit in the middle of the name. The search() function is the only one to change to ask a server for the results.

On phones

The field takes the width of the screen minus 32 pixels and stays 60 tall; the words are 17 pixels (from 16 up iPhone does not zoom the page when the field is tapped) and the keyboard shows the search key. The rows are 48 pixels tall for the finger and are taken by tapping them, without the field losing the focus; with the cross in the lens’s place there are 240 pixels to type in, and the names in the list fit whole even with the column in the middle.

With reduced motion

No springs, no unrolling and no flights: the rows fade in and out already in their place on the column, the yellow stroke fades in instead of running down, the band jumps from row to row and the lens fades into the cross. The recipe taken fades from the list and the name appears in the field; the arrow does not leave the square, it dims and lights up again. The column, the advice and the keys work the same.

Pitfalls

  • The rows are all positioned absolutely, so the list was zero tall and the mask fading its edges (mask-image) cut all of it away: no recipe showed. The list now fills the panel (position absolute, inset 0).
  • The rows that stayed slid to their new place at once and passed over the ones leaving, with words on top of each other: now the ones leaving fade in 0.12 seconds, the ones staying wait 0.08 seconds before moving, and the new rows come in once the others have almost stopped.
  • A closing of the list started with a small delay is not stopped by a new tween’s overwrite: 'auto', because it has not begun yet: emptying the field and typing at once, the list opened and a moment later closed again, empty. Now the tweens of the list’s height are stopped with killTweensOf before every new one.
  • The name rising to the field vanished halfway: it passed under the list, which has a z-index to cover the line of messages. The flight’s layer now has a higher one.
  • The line under the field (5 recipes for “pe”) appeared while the list was still closing, its words over the recipes’: now it waits for the list to have gone back up.
  • While the name slid into the middle of the field the highlighter stayed where it was and for a moment marked the wrong letters: now the flying mark is measured on every frame and the highlighter follows it.
  • To keep the words in the middle the field must leave the same room on both sides: with the cross on the right next to the arrow, a phone had 180 pixels left to type in and Fennel and orange salad did not fit. The cross now takes the lens’s place and the room is 240 pixels.
  • On a phone a tap on a row can take the focus away from the field before the click: the list, closed at once, took the tapped row away with it. Closing on a lost focus now waits 0.2 seconds, and does not happen if the name has meanwhile taken flight.

Libraries

GSAP 3.15.0, Alegreya (font variabile) 5.3.0.

Compressed with gzip, the three demo files weigh 15 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