EmpireUI
Get Pro
← Blog7 min read#css-animation#loading-spinner#react

Loading Spinner Designs: 10 Pure CSS Animations for Every Brand

10 pure CSS loading spinner designs you can drop into any React + Tailwind project — no libraries, no JavaScript, just clean animations that match your brand.

Colorful circular loading spinner animation on dark background

Why Your Loading Spinner Actually Matters

Honestly, most developers treat loading spinners as an afterthought — slap in a border-radius: 50%, throw on a spin keyframe, ship it. Then they wonder why their product feels cheap next to competitors. The spinner is one of the few UI elements users stare at while actively waiting. It's the moment of maximum attention, minimum engagement. What you put there says something about your brand.

A well-designed spinner doesn't need to be flashy. It needs to feel intentional. A SaaS dashboard with a crisp ring spinner radiates control. A creative agency portfolio with a morphing blob feels alive. A developer tool with a terminal-style blinking cursor signals that you know your audience. The animation style you pick is a micro-communication about your entire product.

The good news: every single spinner in this article is pure CSS. No runtime library. No JavaScript animation loop. No canvas. Just keyframes and transforms — which means zero bundle cost and GPU-accelerated rendering on every modern device. Let's build all 10.

The Classic Ring Spinner (And Why It Still Works)

The ring spinner has been around forever and it earns its place every time. It's visually unambiguous — users worldwide immediately read it as "loading" — and it scales cleanly from 16px to 96px without any tweaks.

The trick most developers miss is using border-color with transparency on three sides and a solid color on the fourth, rather than using a conic gradient. The border approach performs better and has near-universal browser support.

``css @keyframes spin { to { transform: rotate(360deg); } } .spinner-ring { width: 40px; height: 40px; border-radius: 50%; border: 3px solid rgba(99, 102, 241, 0.2); border-top-color: #6366f1; animation: spin 0.75s linear infinite; } ` Change border-top-color to your brand token and you're done. Tailwind v4.0.2 users can wire this through a CSS variable: border-top-color: var(--color-primary). The 0.75s` timing feels snappy without being anxious — any faster and it reads as an error state.

Dots, Pulses, and Bouncing Loaders

Three bouncing dots are the second most recognisable loading pattern on the web. You see them in chat interfaces, form submissions, and async search fields. They're friendlier than a ring — less mechanical, more conversational.

The implementation uses animation-delay to stagger each dot's keyframe. With three div elements at 8px diameter and a 6px gap, staggering by 0.15s gives that classic cascade. Don't use margin for spacing here — use gap on a flex container so the rhythm stays consistent at every size.

``tsx export function DotsLoader({ className }: { className?: string }) { return ( <div className={flex items-center gap-1.5 ${className}}> {[0, 1, 2].map((i) => ( <span key={i} className="block h-2 w-2 rounded-full bg-indigo-500" style={{ animation: "bounce-dot 1.1s ease-in-out infinite", animationDelay: ${i * 0.18}s, }} /> ))} </div> ); } /* globals.css */ @keyframes bounce-dot { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } 40% { transform: scale(1.0); opacity: 1; } } ` The scale approach beats translateY for this pattern because it keeps the visual weight centred. Adding the opacity shift makes the active dot really pop against a dark background — try rgba(255,255,255,0.15)` as your container background if you're building on a dark theme.

Skeleton Loaders and Content-Aware Placeholders

Skeleton loaders aren't technically spinners, but they're the best answer to "what should I show while this card loads?" They set spatial expectations before content arrives, which reduces perceived wait time far more than any animation trick. If you're building a content-heavy UI — think dashboards, article feeds, or product grids — you should reach for skeletons first.

The shimmer effect is a single CSS animation on a background-size: 200% linear gradient. Moving it from -100% to 100% on the X axis with background-position gives the wipe. Keep the gradient colours within 8-12% lightness of each other. Too much contrast and it reads as a highlight effect rather than a placeholder.

``css @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } .skeleton { background: linear-gradient( 90deg, rgba(226,232,240,0.8) 0%, rgba(248,250,252,0.95) 50%, rgba(226,232,240,0.8) 100% ); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; border-radius: 4px; } /* dark mode */ @media (prefers-color-scheme: dark) { .skeleton { background: linear-gradient( 90deg, rgba(30,41,59,0.8) 0%, rgba(51,65,85,0.95) 50%, rgba(30,41,59,0.8) 100% ); } } ` Notice the dark mode block uses prefers-color-scheme rather than a .dark class selector. If your app toggles theme with a class, you'd swap that to .dark .skeleton`. For a full theme toggle implementation in React, that setup is covered separately.

Conic Gradient Spinners and the Modern CSS Trick

The conic-gradient() spinner went from niche to mainstream once Firefox 83 landed full support. It creates a swept arc that looks like a progress indicator even when it's just looping — which is exactly what you want during indeterminate loading states.

The technique pairs background: conic-gradient(...) with a mask that punches out the centre, turning the filled cone into a ring. This gives you gradient-coloured stroke without SVG or canvas.

``css .spinner-conic { width: 44px; height: 44px; border-radius: 50%; background: conic-gradient( from 0deg, transparent 0%, #6366f1 70%, transparent 100% ); mask: radial-gradient( farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3px) ); -webkit-mask: radial-gradient( farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3px) ); animation: spin 0.9s linear infinite; } ` The 4px gap in the mask controls your effective stroke width. Bump it to 6px for a chunkier feel, drop it to 2px` for a hairline arc. This style pairs particularly well with aurora-style animated backgrounds because the gradient colour in the spinner can echo the background hues.

Brand-Matched Spinners: Adapting to Style Systems

Here's the thing: the spinner that ships by default in most UI kits has nothing to do with your brand. It's generic purple or blue, 40px, linear timing. For a startup with a defined visual identity, that's friction — every inconsistency trains users to see your product as unpolished.

Mapping spinners to your design tokens takes maybe 20 minutes but the payoff is immediate. If you're on Tailwind v4 with CSS variables, wire your spinner's colour to --color-brand-500. If you're using a glassmorphic card style (see what glassmorphism is and how to implement it), a frosted ring spinner — white with rgba(255,255,255,0.6) opacity on the static ring — integrates perfectly without jarring the eye.

Think about timing too. A linear easing on a ring spinner feels mechanical and reliable — right for a developer tool or a data table. An ease-in-out on a pulsing dot feels organic — right for a social feed or a creative platform. The easing is part of the brand voice. What impression do you want someone to form in the 800ms they're watching your spinner?

You've got eight more animation styles to work with beyond what's shown in the code examples above. Morphing blobs work for creative agencies. Terminal-style blinking cursors work for dev tools. Ripple rings work for notification-heavy apps. The CSS pattern is always the same: one or two keyframe declarations, transform or opacity or background-position, and a well-chosen animation-duration between 0.6s and 2s.

Accessibility: The Part Developers Always Skip

Spinners are almost always missing two accessibility attributes. First, role="status" tells screen readers the region is a live status announcement. Second, aria-label="Loading" (or a localised equivalent) gives that status region a human-readable name. Without both, a visually impaired user has no idea that something is happening.

The prefers-reduced-motion media query is the other non-negotiable. Some users get nauseous from spinning animations. One CSS block handles it: inside @media (prefers-reduced-motion: reduce), set animation: none on all spinner elements and optionally replace with a static indicator. This isn't optional if you care about WCAG 2.1 AA compliance — and most products shipped in 2026 are expected to meet it.

``tsx export function AccessibleSpinner({ label = "Loading" }: { label?: string }) { return ( <div role="status" aria-label={label} className="spinner-ring" style={{ /* respect user preference at the component level too */ animationPlayState: "running", }} > <span className="sr-only">{label}</span> </div> ); } ` The sr-only span is your fallback for assistive technologies that don't fully process aria-label`. Belt and braces. You'll thank yourself when the accessibility audit comes.

Using Empire UI's Animation Primitives for Spinners

Empire UI doesn't ship a single opinionated loading spinner — it ships the animation building blocks. The same @keyframes infrastructure that powers the particle background effects and shooting star backgrounds is available as composable primitives you can drop into your own spinner implementations.

That means you can match the exact motion curve and speed of your background animations to your loading spinners, keeping the whole product feeling cohesive rather than assembled from mismatched parts. Drop a particles-background behind a conic spinner during a file upload and the loading state becomes a design moment rather than dead time.

The 40 visual styles in Empire UI each have their own motion personality — the glassmorphic style uses slow, smooth transitions; the cyberpunk style uses sharp, rapid ones. Picking the spinner timing that matches your chosen style is the last five minutes of work that makes everything feel finished.

FAQ

How do I stop a CSS spinner from causing layout shift when it appears?

Reserve the space before the spinner renders using a fixed-size container (e.g., width: 40px; height: 40px) in your placeholder state. If you're conditionally swapping between a spinner and content, use CSS visibility: hidden rather than removing the element from the DOM — that way the layout box stays intact and no shift occurs.

Should I use a CSS spinner or an SVG spinner?

CSS-only spinners are lighter and easier to style with design tokens. SVG spinners give you more precise stroke control — useful for stroke-dasharray progress arcs — and they scale without any blur at extreme sizes. For a generic indeterminate loading state, CSS is almost always the right call. For a determinate progress indicator where you're animating from 0% to 100%, reach for SVG.

Why does my spinner look blurry on Retina/HiDPI displays?

If you're using border to fake a ring and the element is positioned at a sub-pixel value, the browser anti-aliases the edge. Fix it by ensuring the spinner's top and left (or margin) values are whole pixels. Avoid using translate(-50%, -50%) with odd pixel dimensions — keep width and height as even numbers so the centre calculation lands on a whole pixel.

How do I animate a spinner entering and exiting without a JavaScript library?

Pair the spinner with a CSS @keyframes fade-in on mount and use a short CSS transition on opacity when removing it. In React, combine a boolean state flag with a second data-leaving attribute toggled just before unmounting: flip data-leaving="true", wait transitionDuration milliseconds via setTimeout, then set the state to false. No Framer Motion needed for this pattern.

What's the right `animation-duration` for a loading spinner?

Between 0.6s and 1.2s for most spinners. Faster than 0.6s reads as frantic or error-like. Slower than 1.5s and users start questioning whether anything is actually happening. The sweet spot for ring spinners is around 0.8s linear; for bouncing dots it's 1.0s to 1.2s with ease-in-out. Always test on a real device — GPU-accelerated animations can look different in DevTools throttled mode.

How do I implement prefers-reduced-motion for CSS spinners?

Add a single media query block to your global CSS: @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }. This catches every animation site-wide. If you want a spinner-specific fallback (e.g., a static dot instead of nothing), target the spinner class directly inside the media query and override with a different visual.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Liquid Button Animation: Blob Hover with SVG filterCSS Animations & Motion Design: The Complete 2026 PlaybookTailwind Animation Library: 30 Classes for Common EffectsPage Header Variants: 8 Designs for App and Marketing Pages