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
Erelative 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:
Cbecomes 4Dbecomes 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
leftRankandrightRank, generate a rankmiddleRanksuch 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):
- Take the visible list (sorted by rank)
- Remove the moved card from that list
- Find the new neighbors at the drop location (
prevandnext) - Compute:
newRank = between(prev.rank, next.rank)
- 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.