Skip to content
Back

Building A Loading Button

I recently made a small loading button animation — one of those buttons that shrink and show a spinner when clicked.

It's a simple component, but it's the kind of UI detail that makes an interface feel alive. Let's break down how it's built and what's happening behind the scenes.

Click to activate loading

The Starting Point

We'll begin with a simple, styled button. Nothing fancy yet.

<div class="wrapper">
  <button class="btn">
    <div class="text">Sign In</div>
    <div class="loader"></div>
  </button>
</div>
<div class="wrapper">
  <button class="btn">
    <div class="text">Sign In</div>
    <div class="loader"></div>
  </button>
</div>

Our button has two main elements: .text holds the label "Sign In" .loader the spinning circle that appears when loading starts

We'll toggle between them later using a class.

Basic Button Styling

Let's make the button look clean and centered. Here's why each property matters:

.btn {
  position: relative; /* Needed for absolute positioning of loader */
  display: flex;
  align-items: center;
  justify-content: center;
  width: 120px;
  height: 40px; /* Equal to loading state to prevent jump */
  background: #3b82f6;
  color: white;
  border: 1px solid #3b82f6;
  border-radius: 20px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: width 200ms ease-out; /* Smooth width animation */
}
.btn {
  position: relative; /* Needed for absolute positioning of loader */
  display: flex;
  align-items: center;
  justify-content: center;
  width: 120px;
  height: 40px; /* Equal to loading state to prevent jump */
  background: #3b82f6;
  color: white;
  border: 1px solid #3b82f6;
  border-radius: 20px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: width 200ms ease-out; /* Smooth width animation */
}

Why these specific values?

  • 120px width: Wide enough for "Sign In" text with comfortable padding
  • 40px height: Creates a balanced pill shape when combined with 20px border-radius
  • 200ms timing: Fast enough to feel responsive, slow enough to see the animation
  • ease-out: Starts fast and slows down, feeling more natural than linear

The transition on width is crucial, it creates the smooth shrinking effect when we switch to the loading state.

The Active (Loading) State

Now we want the button to shrink and show the spinner when it's clicked. We'll use an .active class to control that state.

.btn.active {
  width: 40px;
}

.text {
  opacity: 1;
  transition: opacity 200ms ease-out;
}

.btn.active .text {
  opacity: 0; /* Hide text in loading state */
}
.btn.active {
  width: 40px;
}

.text {
  opacity: 1;
  transition: opacity 200ms ease-out;
}

.btn.active .text {
  opacity: 0; /* Hide text in loading state */
}

The magic here:

  • 40px width: Matches the height, creating a perfect circle for the spinner
  • Synchronized timing: Text fade and width change happen simultaneously (200ms)
  • Opacity vs display: Using opacity instead of display:none allows for smooth transitions

When .active is added, the button width reduces from 120px to 40px and the text fades out smoothly, creating space for our spinner.

Adding the Loader

Let’s create a simple CSS spinner. We’ll keep it hidden at first and reveal it only in the active state.

.loader {
  position: absolute; /* Center it over the button */
  width: 20px; /* Half the button height for proportion */
  height: 20px;
  border: 3px solid rgba(255, 255, 255, 0.3);
  border-top: 3px solid #fff;
  border-radius: 50%;
  opacity: 0; /* Hidden by default */
  transition: opacity 200ms ease-out;
  animation: spin 900ms linear infinite; /* Continuous rotation */
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.btn.active .loader {
  opacity: 1;
}
.loader {
  position: absolute; /* Center it over the button */
  width: 20px; /* Half the button height for proportion */
  height: 20px;
  border: 3px solid rgba(255, 255, 255, 0.3);
  border-top: 3px solid #fff;
  border-radius: 50%;
  opacity: 0; /* Hidden by default */
  transition: opacity 200ms ease-out;
  animation: spin 900ms linear infinite; /* Continuous rotation */
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.btn.active .loader {
  opacity: 1;
}

Code breakdown

  • 20px size: Fits comfortably in our 40px button with nice padding
  • 3px borders: Thick enough to be visible, thin enough to look elegant
  • rgba(255,255,255,0.3): Semi-transparent white creates depth without being distracting
  • 900ms duration: Slightly slower than typical (800ms) for a more relaxed feel
  • linear timing: Constant speed rotation looks most natural for spinners

The border-top creates a visible "slice" that makes the spin noticeable. The animation runs continuously but is only visible when the button is in the active state.

At this point, we have all the visual logic in place. We just need to trigger it with JavaScript.

Adding Interactivity with JavaScript

All that's left is to toggle the .active class when the button is clicked. Here's the basic version:

const btn = document.querySelector(".btn");

btn.addEventListener("click", () => {
  btn.classList.toggle("active");
});
const btn = document.querySelector(".btn");

btn.addEventListener("click", () => {
  btn.classList.toggle("active");
});

When you click the button:

  • the .active class is added
  • the button shrinks
  • the text fades out
  • the loader fades in and starts spinning

Clicking again reverses the effect.

CodePen Demo

For an interactive playground, check out this CodePen: Loading Button Demo