When Search Breaks: The Physics of Moving Cards on Our Open-Source Hub

How we fixed a race-condition bug in our search functionality that was causing cards to disappear when clearing the search. Lessons in DOM manipulation and state management when you have 18 tools and no JavaScript framework.

We built neorgon.com to showcase 18 open-source tools we've built over the past few years. The hub is deceptively simple: a searchable grid of cards, each linking to a tool. Under the hood, we've added physics simulations, draggable reordering, and animated search results. It's all vanilla JavaScript—no React, no Vue, no framework. Just plain old DOM manipulation.

And that's where things went wrong.

The Bug Report

"The search bar is failing. Originally it was working, but the current fix damaged the result filtering and when you delete or cancel the search no cards are displayed."

Classic bug report: it was working, then it broke. The user types something in, gets filtered results, clears the search, and... nothing. A blank page where 18 tools used to be.

The Setup: How Search Works

Before we dive into the bug, let's understand the architecture. Our hub is split into "groups"—Planning, DevOps, Data, Productivity, etc. Each group has a label and a .sites-grid container with cards. We use CSS Grid for layout:

HTML Structure
<div class="card-group" data-group="planning">
  <h3 class="group-label">Planning</h3>
  <div class="sites-grid">
    <a class="site-card" data-card-id="pathfinder">...</a>
    <a class="site-card" data-card-id="skillmap">...</a>
  </div>
</div>

The search functionality did two things:

  1. Filtering: When you type, cards that don't match get a .search-hidden class (display: none !important).
  2. Consolidation: Matching cards get moved from their groups into a special "search results grid" at the top of the page.

Why move cards? We wanted a clean, unified results view. No group labels, no empty sections—just the matching cards in one grid.

When you clear the search, the code should:

  1. Move all cards back to their original groups
  2. Remove the .search-hidden class
  3. Remove group indicators
  4. Hide the search results grid

Simple, right? Wrong.

The Problem: Race Conditions and Stale State

Here was the original code (simplified):

The Buggy Code
function doFilter() {
  // Rebuild card collection and index
  var currentCards = Array.from(document.querySelectorAll('.site-card'));

  if (!query) {
    // Clearing search - move cards back
    var searchGrid = document.getElementById('searchResultsGrid');
    if (searchGrid && searchGrid.children.length > 0) {
      var cardsInGrid = Array.from(searchGrid.children);
      cardsInGrid.forEach(function (c) {
        var group = findOriginalGroup(c.dataset.cardId);
        if (group) group.appendChild(c); // BUG #1
      });
    }

    // Then make all cards visible
    currentCards.forEach(function (c) {
      c.classList.remove('search-hidden');
    });
    return;
  }

  // ... filtering logic
}

The Three Fatal Bugs

Bug #1: DOM References vs. Live Collections

Here's the first problem: currentCards is created at the start of the function. It captures all cards that exist in the DOM right now—including cards that are already in the search grid.

Then, we move cards from the search grid back to their groups. But currentCards still has references to those cards in their old positions.

When we try to make all cards visible by iterating through currentCards, we're operating on stale references. If a card has moved, we might be manipulating a node that's no longer where we think it is—or worse, manipulating the same card twice.

Bug #2: Appending to the Wrong Container

Look at line 11: group.appendChild(c). What's group? It's the group container:

<div class="card-group" data-group="planning">
  <h3 class="group-label">...</h3>
  <div class="sites-grid">  // We want to append here!
    <!-- cards -->
  </div>
</div>

We were appending cards directly to the .card-group div, not the .sites-grid container. This broke the layout completely. Cards appeared outside the grid, CSS selectors failed, and everything looked broken.

Bug #3: The Missing Moving Cards

The third bug was the most subtle. When you searched and got results, cards moved into the search grid. When you cleared the search, we moved them back. But here's the sequence:

  1. User types "planning" → 2 cards match → they move to search grid
  2. User clears search → we iterate currentCards → but currentCards was built from cards before we cleared the search grid
  3. The cards that were in the search grid are still in the grid, but now we reference them as if they're in their original groups

Result: some cards ended up in limbo—copied into the search grid during filtering, but never moved back during cleanup.

The Root Cause

The real problem wasn't the code—it was when we ran the code. We were rebuilding the DOM while trying to filter based on the old DOM state.

The Fix: Clear, Build, Filter

The solution was to enforce a strict order:

The Fixed Logic
function doFilter() {
  var q = input.value.trim().toLowerCase();

  // 1. BEFORE ANYTHING: Clean up from previous searches
  var searchGrid = document.getElementById('searchResultsGrid');
  if (searchGrid && searchGrid.children.length > 0) {
    var cardsInGrid = Array.from(searchGrid.children);
    cardsInGrid.forEach(function (c) {
      var group = findOriginalGroup(c.dataset.cardId);
      if (group) {
        var sitesGrid = group.querySelector('.sites-grid');
        if (sitesGrid) sitesGrid.appendChild(c); // Correct container!
        else group.appendChild(c); // Fallback
      }
      c.classList.remove('search-hidden');
      var indicator = c.querySelector('.card-group-indicator');
      if (indicator) indicator.remove();
    });
    searchGrid.style.display = 'none';
  }

  // 2. NOW rebuild the collection from clean state
  var currentCards = Array.from(document.querySelectorAll('.site-card'));

  if (!q) {
    // 3. No search - make everything visible
    allGroups.forEach(function (g) {
      g.classList.remove('group-hidden');
      var cardsInGroup = g.querySelectorAll('.site-card');
      cardsInGroup.forEach(function (c) {
        c.classList.remove('search-hidden');
      });
    });
    document.body.classList.remove('search-active');
    return;
  }

  // 4. Filtering logic only runs on a clean state
  isFiltering = true;
  // ... rest of the filtering code
}

Key Changes

  1. Cleanup first: Move cards back to groups before rebuilding the index
  2. Correct containers: Append to .sites-grid, not the group wrapper
  3. Clean state: Only after cleanup do we query for "current" cards
  4. Defensive filtering: When clearing search, iterate through actual groups, not the stale currentCards array

Lessons Learned

1. DOM State is Expensive

In React or Vue, you'd have a virtual DOM and reactive state. In vanilla JavaScript, you have the actual DOM. Every querySelectorAll is a live look at the DOM. If you're moving elements around, your collections become stale the moment you append a child.

Solution: Establish a clear "source of truth" and rebuild it at the right time. For us, that's after we've restored all cards to their original positions.

2. Defensive Programming > Clever Programming

The original code was trying to be efficient: "I'll just grab all cards once and reuse that collection." But efficiency doesn't matter if it's wrong.

The fixed code queries the DOM multiple times. It's "slower" but it's correct. In client-side JavaScript, querying 30 elements is nanoseconds. Being wrong is forever.

3. The Devil is in the DOM Structure

We had .card-group.sites-grid.site-card. The extra wrapper seemed clean, but it meant we needed to be precise about where cards live. If you can simplify your DOM structure, do it. Every wrapper is another place to lose track of children.

4. Console.log is Your Friend

When debugging this, I added console logs everywhere:

Debugging Output
console.log('Cards in DOM:', document.querySelectorAll('.site-card').length);
console.log('Cards in search grid:', searchGrid.children.length);
console.log('Current cards array:', currentCards.length);

Immediately, I saw the mismatch: 18 cards in the DOM, 3 cards in the search grid, but currentCards had 21. Mystery solved: duplicate references.

The Bigger Picture

This bug taught us something about building "simple" tools: state management is hard, even without state. We weren't using Redux, MobX, or even React's useState. We just had cards in the DOM. And yet, we still managed to create a race condition.

It reminded me of the classic computer science wisdom: "There are two hard things in computer science: cache invalidation, naming things, and off-by-one errors." We hit the trifecta:

  • Cache invalidation: Our currentCards array was a cache of DOM state that we never invalidated
  • Naming things: findOriginalGroup didn't check if the card was currently in a group
  • Off-by-one errors: We were appending to the wrong container, misaligning our entire layout

Bonus: Real-Time Visual Feedback

One of the cooler parts of our hub is the physics-based search visualization. When you search, matching category "pills" zoom to the center, while non-matching ones drift to the edges. It's a subtle UX touch that makes search feel alive.

Here's how it works in the fixed code:

Physics Simulation
pills.forEach(function (p) {
  if (isFiltering) {
    if (p.matched) {
      // Attract to center
      var dx = centerX - p.x;
      var dy = centerY - p.y;
      p.vx += dx * 0.003;
      p.vy += dy * 0.003;
    } else {
      // Drift to orbit position
      var angle = (i / pills.length) * Math.PI * 2;
      p._orbitX = centerX + Math.cos(angle) * radius;
      p._orbitY = centerY + Math.sin(angle) * radius;
      // ... spring physics to orbit
    }
  }
  // ... damping, boundary checks, etc.
});

The physics makes the search experience feel responsive and playful. But it also adds another layer of state: pill positions, velocities, orbit targets. If we lose track of our cards, we lose the visual feedback too.

Pro Tip

When you add physics or animations, you're adding temporal state. The DOM at frame 1 is different from frame 60. Make sure your filtering logic doesn't interrupt the physics simulation mid-tick.

Conclusion

Fixing this bug was a reminder that "simple" UIs hide complex state management. We weren't building a real-time collaborative editor or a multiplayer game. We were just moving cards around. And yet, we still managed to create race conditions, stale state, and layout bugs.

The fix wasn't about adding Redux or rewriting in React. It was about being explicit:

  • Explicit about DOM state vs. in-memory state
  • Explicit about when to rebuild collections
  • Explicit about which container holds which children
  • Explicit about cleanup before new operations

Sometimes the best code isn't clever—it's clear.

And if you're wondering: yes, the search works now. Try it at neorgon.com. Type something, hit backspace, and watch the cards swarm back like starlings. It's beautiful when it works.