Animated Gradient Borders: A Better Loading State for AI Interfaces

Published

31 July 2026

Listen to this post using the player at the bottom of the page.

A traditional loading state is built on an assumption: nothing useful exists yet. The request is either in flight or it's done, so you show a spinner or a skeleton screen, grey out the target area, and swap it for real content the instant the response lands. That model works because the wait is usually short, tens or a couple of hundred milliseconds, and there's genuinely nothing to look at in the meantime.

AI responses break that assumption. A model streaming a reply can take several seconds, sometimes longer, and for most of that time there's already real, readable content on screen. Grey it out or throw a spinner over it and you're hiding the one thing that's actually useful while the user waits: the words appearing in front of them. What you want instead is a signal that says "this is still changing" without obscuring anything, something that sits around the content rather than on top of it.

My answer to that has been a rotating gradient border. A ring of colour sweeps around the edge of the card while the response is generating, then settles into a plain static outline the moment it's done. It doesn't compete with the content, it doesn't block anything, and it reads instantly as "activity in progress" the same way a browser tab's loading favicon does.

Why skeletons and spinners don't fit

Skeleton screens are built for a specific shape of wait: you know roughly what the final layout looks like, you don't have any of the content yet, and you're filling the gap with grey rectangles until it arrives. That's a great fit for a dashboard widget or a list of search results. It's a poor fit for an AI response, because by the time the skeleton would normally disappear, you might only have the first sentence of a four-paragraph answer.

A spinner has the opposite problem. It communicates "wait" perfectly well, but it has nowhere to put the partial content. Either you hide the streaming text behind the spinner until it's finished (defeating the entire point of streaming), or you show the spinner next to the text, which just adds visual noise without connecting to what's actually happening.

What both patterns share is that they treat "loading" as a boolean state layered over the content, rather than a property of the content itself. An animated border flips that: the content stays exactly where it is and behaves exactly as it should, and the "is this still changing" signal moves to the edge, where it doesn't have to fight for attention.

The technique: @property plus a spinning conic-gradient

The core trick is three CSS features working together: a conic-gradient for the colour sweep, a plain animation to rotate it, and @property to make that rotation actually smooth.

css
@property --angle {  syntax: "<angle>";  inherits: false;  initial-value: 0deg;}
@keyframes spin {  to {    --angle: 360deg;  }}
.card {  background: conic-gradient(from var(--angle), #4f46e5, #c026d3, #1f9d63, #4f46e5);  animation: spin 3s linear infinite;}

The part that trips people up is @property. By default, a CSS custom property like --angle is just a string as far as the browser is concerned, it has no type, no unit, nothing to interpolate. Animate it in a @keyframes block without registering it and the browser can't tween between 0deg and 360deg, it just snaps from one to the other on each cycle, which looks like a flicker rather than a spin. @property gives --angle a real type (<angle>), and once the browser knows it's an angle, it knows how to animate it smoothly frame by frame. That's the entire trick behind the "premium" rotating glow you'll see in a lot of recent UI: it's @property plus a conic gradient, nothing more exotic than that.

Browser support is worth a quick note: @property has been in every major engine (Chrome, Edge, Safari, Firefox) since 2024, so this is safe to ship without a fallback in anything targeting a reasonably current browser. If you do need to support something older, the border just won't rotate, it'll sit static, which is a perfectly reasonable degradation for a decorative effect.

Turning that into an actual border

background: conic-gradient(...) on .card fills the whole element, which isn't a border, it's the entire background. The border effect comes from a second layer: an outer element carries the spinning gradient as its background, padded out by a couple of pixels, and an inner element sits on top of it with a solid background colour and a slightly smaller border radius. What's left visible of the outer element is exactly the padding, which reads as a rotating coloured ring.

jsx
// src/components/elements/GradientLoadingBorder.js"use client";
export const GRADIENT_LOADING_BORDER_VARIANTS = {  brand: ["#4F46E5", "#C026D3", "#1F9D63", "#4F46E5"],  rainbow: ["#EF4444", "#F59E0B", "#22C55E", "#3B82F6", "#8B5CF6", "#EF4444"],  aurora: ["#22D3EE", "#818CF8", "#C026D3", "#22D3EE"],};
export function GradientLoadingBorder({  active = true,  variant = "brand",  colors,  speed = 3,  reverse = false,  thickness = 2,  radius = 16,  className = "",  children,}) {  const stops = colors ?? GRADIENT_LOADING_BORDER_VARIANTS[variant] ?? GRADIENT_LOADING_BORDER_VARIANTS.brand;
  return (    <div      data-active={active}      className={`gradient-loading-border ${className}`}      style={{        "--gradient-loading-border-gradient": `conic-gradient(from var(--gradient-loading-border-angle), ${stops.join(", ")})`,        "--gradient-loading-border-thickness": `${thickness}px`,        "--gradient-loading-border-radius": `${radius}px`,        "--gradient-loading-border-inner-radius": `${Math.max(radius - thickness, 0)}px`,        "--gradient-loading-border-duration": `${speed}s`,        "--gradient-loading-border-direction": reverse ? "reverse" : "normal",      }}    >      <div className="gradient-loading-border__inner">{children}</div>    </div>  );}

The gradient itself is built per-instance in JS and handed to the CSS as one custom property (--gradient-loading-border-gradient), rather than trying to pass a colour list through CSS and reassemble the conic-gradient(...) call in the stylesheet. That's a deliberate choice: conic-gradient() isn't something you can partially assemble from smaller custom properties, so the cleanest boundary is "JS owns the colour stops, CSS owns the animation."

The actual animation lives in global CSS, registered once and reused by every instance of the component:

css
@property --gradient-loading-border-angle {  syntax: "<angle>";  inherits: false;  initial-value: 0deg;}
@keyframes gradient-loading-border-spin {  to {    --gradient-loading-border-angle: 360deg;  }}
.gradient-loading-border {  position: relative;  border-radius: var(--gradient-loading-border-radius, 16px);  padding: var(--gradient-loading-border-thickness, 2px);  background: var(--gradient-loading-border-gradient);  animation: gradient-loading-border-spin var(--gradient-loading-border-duration, 3s) linear infinite;  animation-direction: var(--gradient-loading-border-direction, normal);  transition: background 400ms ease;}
.gradient-loading-border[data-active="false"] {  animation-play-state: paused;  background: var(--outline);  padding: 1px;}
.gradient-loading-border__inner {  border-radius: var(--gradient-loading-border-inner-radius, 14px);  background: var(--surface);}

That [data-active="false"] rule is doing the actual "toggle" work. When active is false, the animation pauses in place, the gradient background swaps out for a flat outline colour, and the padding drops from 2px to 1px so the settled state reads as a normal card border rather than a fat coloured band. Nothing about the content or layout shifts, only the border's own state changes.

This is also documented as a live component in this site's own Elements library, with all three variants below wired up to real controls if you want to poke at every prop rather than read about it.

Wiring it to a real loading state

The point of taking active as a prop rather than hardcoding the animation is that it's meant to track something real, usually whatever boolean your streaming hook already gives you (isLoading, status === "streaming", whatever your SDK calls it). Here's the pattern with the Vercel AI SDK's useChat, which exposes exactly that:

jsx
import { useChat } from "@ai-sdk/react";import { GradientLoadingBorder } from "@/components/elements/GradientLoadingBorder";
export function ChatMessage({ message }) {  const { status } = useChat();  const isStreaming = status === "streaming";
  return (    <GradientLoadingBorder active={isStreaming} variant="brand" className="w-full max-w-md">      <div className="p-4" aria-live="polite" aria-busy={isStreaming}>        {message.content}      </div>    </GradientLoadingBorder>  );}

Below is the same component wired to a fake stream instead of a real one, so you can see both states without needing an API key. Flip the switch and watch the border pick up mid-response and settle once the text stops.

AssistantWriting

Old loading-state habits that still apply

A rotating border is a new coat of paint, not a reason to throw out everything you already know about building loading states. Two habits carry straight over.

Reserve the box's width before the first character arrives. The bug to watch for: you wrap a card in GradientLoadingBorder and pass a width class to the inner content div instead of the border wrapper itself, and it does nothing. The wrapper is the element actually participating in layout, if it has no width of its own it shrinks to fit whatever's inside it, which at the start of a stream is one word. The card then visibly grows sideways for the first second or two before settling, exactly the kind of shift you'd flag in a review for a normal skeleton screen. Put the width where the layout decision actually happens:

jsx
<GradientLoadingBorder active={isStreaming} className="w-full max-w-md">  <div className="p-4">{message.content}</div></GradientLoadingBorder>

Height is a different story. Letting the card grow taller as more lines stream in is the entire point of this pattern, that's the content the user came to read. Fix the axis that shouldn't move (width), leave the one that should (height).

The spinning border is a purely visual cue, so back it with a real one. Everything in this post so far communicates "still working" to sighted users only. Screen reader users get nothing unless the content region itself is marked up properly: aria-busy on the container while it's updating, aria-live="polite" so assistive tech announces new content without interrupting whatever the user is doing. This isn't specific to gradient borders, it's the same rule you'd apply to a skeleton screen or a spinner, it's just easy to forget once the visual signal looks convincing enough on its own.

Putting the border on the input, not the response

In practice I move this animation off the response bubble more often than I put it on one. The response gets a plain skeleton that resolves into real streamed text, nothing spinning there, and the border goes on the prompt input underneath instead. The input is the one element still fully interactive while a response is generating, and that's exactly where "still working" feedback is most useful: a user thinking of a follow-up shouldn't have to wait for the current answer to finish before they start typing it.

The input stays enabled the whole time, only its border animates. Submitting while active is still true doesn't fire the request immediately, it queues, the same way most chat products already handle a message sent mid-stream:

jsx
function Composer({ isStreaming, sendMessage, queueMessage }) {  return (    <GradientLoadingBorder active={isStreaming} variant="brand" className="w-full">      <div className="p-1">        <PromptInput          onSubmit={(message) => {            const text = message?.text?.trim();            if (!text) return;            isStreaming ? queueMessage(text) : sendMessage(text);          }}        >          <PromptInputBody>            <PromptInputTextarea placeholder="Ask a follow-up…" />          </PromptInputBody>          <PromptInputFooter className="justify-end">            <PromptInputSubmit />          </PromptInputFooter>        </PromptInput>      </div>    </GradientLoadingBorder>  );}

Below is the full flow: a skeleton while the first token is still on its way, streamed text once it lands, and a composer underneath that never locks up, type into it while the border is spinning and submit queues your follow-up instead of blocking on it.

AssistantThinking

 

Picking a palette

The colour stops you pass to conic-gradient() change the personality of the effect more than almost any other knob. A tight two or three colour sweep pulled from your own brand palette reads as calm and "on-brand." A full rainbow reads as more playful, closer to what you'd see on a game loading screen. A narrow cyan-to-violet pair rotating slowly gives a softer, drifting aurora feel that suits a hero card better than a tight chat bubble. None of these are more "correct" than the others, they're a design decision like any other, but it's worth actually trying a few side by side rather than guessing.

variant: brand

Speed matters as much as colour. Three seconds per rotation reads as calm and "still thinking." Push it under a second and it starts to read as urgent or broken, less "the AI is working" and more "something's wrong with my browser." I've settled on somewhere between 2 and 3 seconds as the sweet spot for a response card, and slower, 5 to 8 seconds, for anything larger like a full-page hero.

Respecting prefers-reduced-motion

A ring of colour sweeping continuously around a card is exactly the kind of motion prefers-reduced-motion: reduce exists for. Don't just ignore it because the animation feels essential to the effect, disable the rotation and fall back to a static gradient. You keep the "something is happening" colour cue without the continuous motion:

css
@media (prefers-reduced-motion: reduce) {  .gradient-loading-border {    animation-name: none;  }}

This is also the argument for exposing active as a manual, user-facing toggle in the first place, not just an internal prop wired to your loading state. Give people a real switch in their settings to turn decorative motion off entirely, independent of whatever their OS-level prefers-reduced-motion setting is (not everyone has that configured, and not everyone who wants less motion has gone digging in their OS accessibility settings to set it). A toggle costs you one boolean and a Switch component. Not having one means the only way to opt out is closing the tab.

When to reach for this

This pattern earns its keep specifically when content is streaming in and staying visible the whole time: chat responses, AI-generated summaries, live-editing suggestions, anything where the user is reading partial output rather than staring at a blank container. If your loading state is genuinely a blank container with nothing behind it yet, a skeleton screen is still the better tool, there's no content to protect from being covered up.

It's also not free. A continuously animating conic-gradient on a typed custom property is running every frame, and if you've got a dozen of these on screen simultaneously (a list of chat messages all streaming at once, say), it's worth checking actual frame timing rather than assuming it's fine. In most single-card cases it's a non-issue. In a busy multi-message chat view, cap it to the message that's actively streaming and let finished ones settle into their static state, which the active prop already gives you for free.