Volver al blog
Dark mode, skeletons, and micro-animations: the details that make software feel alive
Engineering

Dark mode, skeletons, and micro-animations: the details that make software feel alive

7 min read|12 de mayo de 2026|Neural Summary

There is a category of engineering work that does not add features. It does not fix bugs. It does not improve performance in a way that shows up in metrics. It makes software feel alive.

Dark mode without the white flash. Loading states that look like the page they are replacing. Entrance animations that are subtle enough to feel natural and fast enough to not feel slow.

This post covers three polish projects we shipped, the specific decisions behind each one, and what they changed about the way our application feels.

Eliminating the dark mode white flash

Users with dark mode enabled were seeing a white flash on every page navigation. For a fraction of a second, the page rendered white before the dark theme applied. It was technically harmless. It felt broken.

The cause was architectural. Our theme was applied by React after hydration, which runs after the browser paints. The browser rendered the default light styles first, then JavaScript read the user's preference and applied the dark class. That gap between first paint and theme application was the flash.

Why an inline script, not React

We needed the theme class on the <html> element before the first pixel rendered. That ruled out React, hooks, state management, or any external script. The only approach that works is a synchronous inline script in the <head>:

<head>
  <script>
    (function() {
      var theme = localStorage.getItem('theme');
      if (!theme) {
        theme = window.matchMedia('(prefers-color-scheme: dark)').matches 
          ? 'dark' : 'light';
      }
      document.documentElement.classList.add(theme);
    })();
  </script>
</head>

This script runs during HTML parsing, before paint. By the time the first pixel renders, the CSS variables for the correct theme are already active. No flash.

The tricky part was making React not fight it. When React hydrates, it reads the theme from the same localStorage key. If the React theme hook removed and re-added the class, it would reintroduce the flash. We also added a data-force-theme attribute for pages where the theme should not be overridden, like shared transcript pages that are always light.

What this changed: A small architectural decision (inline script before React) eliminated the most visible quality issue in the application. Users with dark mode enabled no longer see any visual glitch on navigation.

Replacing every spinner with layout-matched skeletons

A loading spinner tells the user "something is loading." A skeleton tells the user "here is what is loading."

Before skeletons, our dashboard showed a centered spinner while data loaded. When the content appeared, the layout shifted, cards popped in, and the page visually jumped. The 200-500ms loading time felt longer than it was because the user had no mental model of what was coming.

The constraint: skeletons must match the real layout

The most important rule we established: skeleton layouts must use the same grid, gap, padding, and border radius as the real component. If the skeleton shows three cards in a row but the loaded page shows two, the transition feels wrong.

// Real dashboard
<div className="grid grid-cols-3 gap-4">
  {conversations.map(c => <ConversationCard key={c.id} />)}
</div>

// Dashboard skeleton — same grid, same gap, same padding
<div className="grid grid-cols-3 gap-4">
  {[1, 2, 3, 4, 5, 6].map(i => (
    <div key={i} className="rounded-lg border p-4">
      <Skeleton className="h-4 w-3/4 mb-2" />
      <Skeleton className="h-3 w-1/2 mb-4" />
      <Skeleton className="h-3 w-full" />
    </div>
  ))}
</div>

When the real content arrives, we crossfade with a 200ms CSS opacity transition. The skeleton fades out while the content fades in. This is smoother than an instant swap, which feels jarring when the layout is complex.

Where this mattered most

We replaced every spinner in the application:

  • >Dashboard: Cards in a grid matching real card dimensions
  • >Conversation page: Title bar, metadata chips, content area, and sidebar
  • >Auth guard: Full-page skeleton while verifying authentication

The auth guard skeleton was the biggest win. Previously, users saw a blank white page for 200-500ms on every page load while the auth check ran. Now they see the navigation sidebar and a content placeholder. The page feels like it loads instantly, even though the actual data fetch takes the same amount of time.

What this changed: Perceived loading time dropped significantly. The application went from "pop and shift" to "smooth and expected." Same data-fetching speed, different user perception.

Adding micro-animations with architectural guardrails

We added entrance animations across the application, but with strict rules to prevent them from becoming a performance or accessibility problem.

The rules we set before writing any animation code

GPU-composited properties only. We animate opacity and transform (translate, scale). Never width, height, top, left, or any property that triggers layout recalculation. GPU-composited animations run on the compositor thread, so they cannot cause frame drops regardless of main thread load.

Respect reduced motion. Users who have prefers-reduced-motion: reduce enabled see no animations. This is not optional. It is an accessibility requirement.

const prefersReducedMotion = 
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;

if (prefersReducedMotion) {
  return <div>{children}</div>; // No animation wrapper
}

Short durations. Entrance animations are 200-400ms. Anything longer feels slow. Anything shorter is imperceptible. The sweet spot for most elements is 300ms.

Stagger, do not synchronize. When a group of elements appears (dashboard cards, sidebar items), each element enters with a 40ms delay after the previous one. This creates a cascade effect that feels organic. For a grid of 12 cards, the full cascade takes 480ms.

Two reusable components, not per-element animation

We built two components that handle all animations in the application: AnimateIn for individual elements and StaggerList for groups. Every animation in the app uses one of these two primitives. No one-off animation code.

The total animation budget across a page load is under 500ms. Every animation serves a purpose: directing attention, showing hierarchy, or smoothing a transition. None are decorative.

What this changed: Pages went from static rendering to feeling alive. Content cascades into view, modals slide, and sidebar items stagger. The rules we set upfront (GPU-only, reduced motion, short durations) prevented animation from becoming a performance or accessibility regression.

The waveform audio player

Pro users can play back their original recording. Before we built the custom player, the interface used a standard HTML audio element. It worked, but it gave users no visual sense of the recording's structure.

The waveform is pre-computed during transcription processing. We use a command-line audio tool to extract amplitude data at regular intervals, then store the normalized values alongside the transcription. This means the waveform renders instantly when the page loads, without downloading or analyzing the audio file on the client.

During playback, the waveform fills from left to right with the brand purple color, showing progress through the recording. Clicking on the waveform seeks to that position. For conversations processed before we added waveform generation, the player falls back to a placeholder pattern.

What this changed: The audio player went from a generic browser control to a visual representation of the recording that users can scan and navigate. It makes long recordings (1-2 hours) feel manageable instead of opaque.

What a week of polish changed about the product

Dark mode flash prevention: one afternoon. Skeleton loading states: two days. Micro-animations: one day. Waveform player: two days. Total: about a week of engineering time.

The application before these changes was functional. Every feature worked. Every page loaded. The application after feels different. Pages load smoothly instead of popping. Themes apply instantly instead of flashing. Content cascades into view instead of appearing all at once.

None of these changes show up in a feature comparison. None of them would appear in a competitor matrix. But they are the difference between software that works and software that feels good to use.

We adopted a principle from this: polish is not a phase at the end. It is a set of architectural decisions (inline scripts, layout-matched skeletons, GPU-only animation rules) made alongside feature work. The constraints we set upfront, not the animations themselves, are what made the polish sustainable.

Seguir leyendo