Skip to content
Back to the Bootcamp
The Momentum Bubble Bootcamp, free. Part 6 of the paid course that trained hundreds of Bubble developers — released free, because the thinking it teaches is exactly what great AI-assisted development is built on.

Bootcamp

Sortable table headers with conditional arrows in Bubble

Sortable table headers with conditional arrows in Bubble

Photo by Anders Jildén on Unsplash

A table users can’t sort is a table users slowly stop trusting. The moment a list outgrows one screen, people want the biggest deals at the top or the renewal dates about to lapse, and they expect to get there by clicking a column header, the way every serious product lets them.

Most Bubble builders get partway there with a hard-coded sort, then hit a wall when every column seems to need its own search expression. There is a cleaner method, it scales to any number of columns, and it produces the arrows users expect.

A sortable table header is a clickable column label that rewrites the list’s sort order. It stores two things, the sort field and the sort direction, somewhere durable, and it displays the current state with an arrow icon so users always know how the table is ordered.

How the method fits together

Building this in a CRM contacts table takes five pieces:

  • A header row whose column groups mirror the table’s column widths.
  • An option set that maps URL-friendly names to real database field names.
  • Sortable “trigger” fields on the Contact data type, because Bubble can’t sort by everything.
  • Click workflows that write sort and desc parameters into the page URL.
  • One search on the repeating group, using Bubble’s Change which field feature, plus conditional arrows.

The state lives in the URL, not in a custom state. Reload the page with a custom state and your sort is gone; reload with URL parameters and everything is exactly where the user left it. It also means a sorted view can be shared as a link.

Step 1: build the header row

  1. Copy the row group from the repeating group’s top cell (the cell background group) and paste it above the table, inside the page’s content group. Make it the first element. Reusing the cell group means the header inherits the same internal spacing as the rows below it.
  2. Replace the row’s dynamic texts with static labels: Contact name, Deals, Total value, Renewal date, Status. Use your medium bold text style so headers read as headers.
  3. Because the table’s column groups are fixed widths, keep the header’s column groups at the same fixed widths so everything lines up. Match each column’s alignment too: left-aligned columns get left-aligned headers, centred columns get centred headers.
  4. Drop a Material Icon next to each label: 24 by 24, up arrow as the default. Set the spacing gap inside each header group to 8 rather than 16 so the arrow sits close to its label. Some columns will now be tight, so widen them (in the build, Deals went from 76 to 100 pixels and Renewal date out to 140, with the row cells matched to suit).
  5. Name the whole thing Group Table Header, give it a white background, and fix its height at 40 pixels.

That last point earns a note: in the original build the header row vanished in preview, and the debugger showed the group present but not visible. The cause was an unconstrained height collapsing to nothing. Fixing the height at 40 solved it, and finding that took thirty seconds in the debugger instead of thirty minutes of guessing.

Step 2: create the sort option set

Go to the Data tab, then Option sets, and create Contact Sort OS (a suffix like OS makes option sets easy to spot in dropdown lists later). Add one option per sortable column: name, deals, totalvalue, renewaldate, status. Keep every display value lowercase with no spaces, because these values are going to appear in your URL, and ?sort=totalvalue looks a lot better than ?sort=Total%20Value.

Then add an attribute to the option set called field name, type text. This is where each option stores the exact database field name it sorts by, and it’s the hinge the whole method turns on.

Step 3: add sortable trigger fields

Here’s the catch the tutorials skip: Bubble can’t sort a search by every field. A contact’s displayed name is composed from first name and last name, and things like deal counts and totals live on related records, none of which a search can sort by directly. The fix is to create plain fields that exist purely for sorting, on the Contact data type:

  1. full name trigger (text)
  2. deals trigger (number)
  3. total value trigger (number)
  4. renewal date (date)
  5. status trigger (text)

It’s important that numbers are numbers: sort a numeric value stored as text and 10 lands before 2. These are called trigger fields because in a production app they’re kept up to date by database triggers or calculated on save (populating full name trigger as Input First Name's value Input Last Name's value in the create workflow, for instance). While building, set them manually in the App data tab.

Now go back to the option set and fill in each option’s field name attribute with the exact name of its trigger field. Copy and paste the names rather than retyping them; one silent typo here costs an hour later.

One hard-won warning: if you ever delete an option, a field or a data type and recreate one with the same name, make sure nothing still references the deleted original. Options carry a hidden ID, the debugger won’t reveal that you’re pointing at a deleted one, and this exact mistake is a recurring source of “impossible” bugs.

Step 4: type each header group with the option set

Now the clever part. Set each header column group’s type of content to Contact Sort OS, and its data source to Get an option with the matching option: the name column gets name, the deals column gets deals, and so on.

This feels redundant. It isn’t. Every workflow and conditional you’re about to write will refer to the parent group’s option rather than a hard-coded value, which means you build the logic once and copy it everywhere untouched.

Step 5: write the sort into the URL

  1. On the name header group, add a workflow: when Group Name is clicked, use Navigation, then Go to page and choose the contacts page itself. Sending yourself to the current page just rewrites the parameters.
  2. Tick Send more parameters to the page, add a key called sort (lowercase again), and set its value to This Group's Contact Sort OS's display.
  3. Copy that workflow onto every other header group. Because the expression references the parent group’s own option, nothing needs editing per column.

Preview and click each header in turn, watching the URL: sort=name, sort=deals, sort=totalvalue. In the original build, the status column silently reused another column’s option, which is exactly why you test each unit of function as you go. When Bubble seems buggy, 99.95 per cent of the time the problem is sadly with you, not with Bubble; persist, learn to debug, and you will get there.

Now handle direction with a second workflow per header:

  1. Add a condition to the original workflow: only when Get desc from page URL is not "yes". Have it send desc = yes alongside sort.
  2. Copy that workflow and flip it: only when Get desc from page URL is "yes", sending desc = no.

Why “is not yes” rather than “is no”? Because on first load there’s no desc parameter at all. An empty parameter can’t equal yes, so the first click on any column flips it straight into descending while the untouched default stays ascending. Copy both workflows across all five headers systematically, then test the toggle.

This is the moment all the setup pays off. Open the repeating group’s data source, Search for Contacts, and in the sort settings choose Change which field. Set the field name to Get sort from page URL, typed not as text but as Contact Sort OS, then append 's field name.

Read that back: the URL supplies an option, the option supplies the real database field name, and the search sorts by it. One search now serves every column.

For direction, point the search’s Descending setting at an expression built on Get desc from page URL. The parameter comes back as yes or nothing, and the expression evaluates to the yes/no the search needs, reversing the order whenever descending is on.

Populate some trigger fields with real values so you can watch it work, and while you’re in the row cells, format the money (:formatted as currency, no decimal places, comma separator) and the dates. Sorting is far more convincing when $5,000 looks like $5,000.

Step 7: conditional arrows

The arrows should only appear on the active column, pointing the right way:

  1. On each icon, set This element is visible on page load to no and Collapse when hidden to yes. The default icon stays the up arrow.
  2. First conditional, visibility: when Get sort from page URL (typed as Contact Sort OS) is Parent group's Contact Sort OS, this element is visible. You’re comparing an option to an option, no text matching involved.
  3. Second conditional, direction: when Get desc from page URL is not empty, change the icon to the down arrow. Up by default, down when descending is in play.

Copy both conditionals to every other arrow, again with zero edits, because everything routes through the parent group’s option. Preview: one arrow visible at a time, flipping as you toggle, following you from column to column.

The anti-pattern to avoid

The common alternative is a stack of conditionals on the repeating group: one search expression per column, sorted by that column, shown when the matching URL value is present. Five columns and two directions later you’re maintaining twenty conditionals, and adding a column means writing more. The Change which field approach has almost nothing to maintain, because the dynamic value lives on the header groups themselves.

Two closing principles from the build. First, use the database the way it’s designed (sorting server-side, state in the URL) and it pays you back in time saved and performance. Second, this approach performs identically at three rows and at thousands, and that’s the test worth knowing: if a table is fast with four rows, you’re set for scale, and if it’s slow with four rows, you’re already in trouble.

Try it yourself

Add one more sortable column to a table you’ve built: create the trigger field, add the option with its field name attribute, drop in the header group and arrow, and copy the workflows and conditionals across. If the method is set up correctly, the whole exercise takes about five minutes, and that’s the point. While you’re there, give empty cells a placeholder: a conditional that shows NA when the field is empty, or the inline :format as text trick, which lets you put an if/else directly inside a text expression. Placeholders in every column stop an app looking unfinished.

This skill in the AI era

Strip away the Bubble editor and look at what you actually built: sort state in the query string, a lookup table mapping public names to database fields, and one parameterised query instead of twenty branches. That is precisely how a professional web app handles sorting, whether the query ends in a Bubble search or a SQL ORDER BY. When we direct Claude Code to build a data table, the spec we give it is this article’s architecture, and knowing it is what separates directing an AI from accepting whatever it produces, which is the through-line of moving from Bubble to AI-assisted coding. It’s also the level our Claude Code developers in Sydney work at every day: patterns first, tools second.

If this sparked something, let's talk.

No pitch, no pressure — just a conversation about what you're working on.

Let's talk
Share: