LexoRank: Drag & Drop Ordering (Even While Filtered)

Written with ChatGPT assistance; curated and edited by Sander. 2 weeks ago

If you’ve used Jira, you’ve probably reordered issues on a board by dragging cards to a new position.

What’s even more interesting is this:

You can reorder cards while a filter is active (for example: Assignee = Alice)
and everything still makes sense when you remove the filter.

That behavior feels obvious as a user — but implementing it correctly is trickier than it looks, especially at scale.

This article covers:

  • why naive ordering breaks,
  • why floating-point ranks eventually fail,
  • and how LexoRank solves the problem cleanly.

The problem

A Kanban column is an ordered list:

A, B, C, D, E

A drag operation changes the order, for example moving E between B and C:

A, B, E, C, D

Now introduce filtering.

Say the board shows only cards assigned to Alice:

A, C, E

If the user drags E to the top, what should happen?

The natural expectation is:

“Move E relative to what I can see,
and don’t rearrange hidden cards.”

That single expectation drives the entire design.


One order, many views

The clean way to model this is:

The column has one global order.
Filters are just views of that order.

In practice, each card stores an order key, and the UI renders:

  • All cards: sorted by that key
  • Filtered cards: subset of cards, still sorted by that key

This immediately gives you the correct behavior:

✅ no “per-filter ordering”
✅ removing filters doesn’t scramble items
✅ only the moved card needs updating

Now the real question becomes:

What kind of order key should you store?


Integers

The simplest model is to store integer positions:

Card Position
A 1
B 2
C 3
D 4

The issue: inserting between items often forces renumbering.

Moving E between B and C means:

  • C becomes 4
  • D becomes 5
  • …and so on

That quickly turns into a lot of writes and contention when the list is large and frequently edited.


Floats

A common improvement is using floating-point ranks:

A=1.0, B=2.0, C=3.0, D=4.0

Insert between B and C:

E = (2.0 + 3.0) / 2 = 2.5

Only one card changes. Great.

But repeated inserts into the same gap cause ranks to collapse:

2.5
2.25
2.125
2.0625
...

Eventually floating point precision runs out and you can’t produce a value strictly between two ranks:

mid = (prev + next) / 2
mid == prev

At that point ranks collide (or become indistinguishable), and the system starts misbehaving:

  • cards “jump” after refresh
  • ordering becomes unstable
  • pagination can return inconsistent results

Floats are okay for prototypes, but they degrade in long-running systems.


LexoRank

LexoRank avoids float precision problems by using sortable strings.

Each card gets a rank like:

"0|hzz"
"0|i00"
"0|mK3"

The list is ordered by:

ORDER BY rank ASC

Ranks are made from a fixed alphabet, commonly something like:

0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz

This is basically a base-62 number stored as a string.

The key idea:

When there’s no space “between” two ranks, LexoRank creates more space by using more digits.

Instead of squeezing into smaller float gaps, it increases precision by extending the string.


Between(a, b)

Drag-and-drop reordering only needs one operation:

Given leftRank and rightRank, generate a rank middleRank such that:

leftRank < middleRank < rightRank

This operation is usually called between(a, b).

A simplified intuition is “midpoint, but on strings”:

  • If there’s room at the current digit, choose a digit in the middle.
  • If there isn’t room, copy digits and go deeper (add more characters).

That’s what makes LexoRank effectively “insert-forever”.


Filtering + reorder

To support Jira-style filtering, the system needs one critical rule:

Only update the moved item’s rank.
Never rewrite the hidden items.

The algorithm (works the same for filtered and unfiltered views):

  1. Take the visible list (sorted by rank)
  2. Remove the moved card from that list
  3. Find the new neighbors at the drop location (prev and next)
  4. Compute:
newRank = between(prev.rank, next.rank)
  1. Save only the moved card

Hidden cards keep their ranks, so they stay stable.


Example

Global ordering:

A(aa), B(bb), C(cc), D(dd), E(ee)

Filter: Assignee = Alice

Visible list:

A(aa), C(cc), E(ee)

Move E between A and C.

Neighbors in the filtered view:

  • prev = A(aa)
  • next = C(cc)

Update only E:

E.rank = between("aa", "cc")

Now the global order becomes:

A, B, E, C, D

Nothing else changed, and the board remains intuitive both filtered and unfiltered.


Notes

LexoRank is conceptually simple, but a solid implementation needs a few details right:

  • Use binary/ASCII collation in the database (avoid locale weirdness)
  • Always add a stable tie-breaker:
    ORDER BY rank, id
  • Rebalancing is still possible, but it’s rare compared to float systems

Wrap-up

Drag-and-drop reordering is really a data modeling problem.

A Jira-like solution is:

  • one global order key per card
  • filtering as a view
  • update only the moved card
  • use LexoRank strings to stay stable over time

LexoRank is a practical choice for boards, backlogs, playlists, and any list users reorder repeatedly.