Building a loading button in React with TypeScript, shadcn/ui and Tailwind CSS v2

René Kulik on 13.01.2025

Introduction

In a previous post, I demonstrated how to build a loading button in React using useLayoutEffect to calculate and preserve the button’s width while displaying a loading spinner. While that approach works well, it relies on JavaScript for layout calculations. In this post, I’ll show you an alternative technique using pure CSS for the loading indicator, leveraging Tailwind CSS’s group and group-data utilities to style elements based on the parent’s state. This solution was inspired by a post from jordi on X.

Understanding the problem

When building a loading button, one common issue is the button’s content changing width when the loading indicator is displayed. In the first version, we solved this by dynamically calculating the button’s width with JavaScript. However, this approach can be avoided by creatively using CSS opacity and Tailwind’s state-based utilities.

The plan

In this version, we’ll:

  1. Use Tailwind CSS classes to style the button and loading indicator.
  2. Use the data-loading attribute on the button to toggle styles.
  3. Ensure smooth transitions between the button’s normal state and its loading state without any JavaScript-based width calculations.

The code

import { LoaderCircleIcon } from 'lucide-react';
import { FC } from 'react';
import { Button, ButtonProps } from '@/components/ui/button';
import { cn } from '@/utils/shadcn';

interface LoadingButtonProps extends ButtonProps {
  isLoading?: boolean;
}

export const LoadingButton: FC<LoadingButtonProps> = (props) => {
  const {
    isLoading = false,
    disabled,
    className,
    children,
    ...restOfProps
  } = props;

  return (
    <Button
      {...restOfProps}
      data-loading={isLoading}
      className={cn(className, 'group')}
      disabled={disabled ?? isLoading}>
      <LoaderCircleIcon className="absolute animate-spin opacity-0 group-data-[loading=true]:opacity-100" />
      <span className="flex size-full items-center justify-center group-data-[loading=true]:opacity-0">
        {children}
      </span>
    </Button>
  );
};

Key concepts

The data-loading attribute

Instead of using JavaScript to manipulate styles directly, we’re adding a data-loading attribute to the button. This attribute acts as a hook for styling elements inside the button.

Tailwind CSS group utilities

We use the group class on the button and the group-data-[loading=true] utility to style child elements based on the button’s data-loading state. For example:

Absolute positioning

The loading spinner is absolutely positioned within the button, ensuring it remains centered regardless of the button’s content.

Advantages of this approach

Tailwind CSS breakdown

Here’s a breakdown of the key Tailwind classes used:

Comparison with the previous approach

Feature Previous version Current version
Width management JavaScript (useLayoutEffect) Pure CSS with opacity
Styling Inline and Tailwind CSS Tailwind CSS group utilities
Dependencies Required DOM measurements No additional dependencies

Conclusion

This CSS-based approach simplifies the implementation of a loading button, avoiding JavaScript-based layout calculations while leveraging the power of Tailwind CSS. By using group and group-data, we achieve a clean and maintainable solution.

Try this technique in your next project and see how it compares to the JavaScript-based approach!