Skip to content

The Apple Intelligence Gradient in CSS, Step by Step

The conic-gradient and @property recipe behind the Apple Intelligence glow, with a copy-paste CSS ring, Tailwind and SwiftUI variants.

· · 15 min read
A soft mesh gradient blending violet and blue, the color family the Apple Intelligence glow sweeps through

The Apple Intelligence glow is a conic gradient rotated by an @property-typed angle, masked down to a 2px ring on a pseudo-element. Four colors, fixed order, 1.8 seconds a loop. The whole effect is about forty lines of CSS and no JavaScript, and the one line everybody leaves out is the @property declaration.

That omission is why this effect has a reputation for being finicky. The code looks right, the gradient paints, and nothing moves. So this tutorial builds it from an empty div in the order the pieces actually depend on each other, then covers the parts that only bite you after you ship: what it costs to run several of these at once, what happens in a browser without @property, and why the amber in Apple's palette is a contrast problem the moment you put it on a light background.

TL;DR: Register --angle with @property (syntax <angle>), sweep a conic-gradient(from var(--angle), ...) across a pseudo-element inset past the card edge, mask it to the border zone with mask-composite: exclude, and animate the angle to 360deg over 1.8s linear. A second blurred pseudo-element behind the card gives you the halo. Wrap the whole thing in prefers-reduced-motion and it ships.

Photo by Codioful on Unsplash

What Makes the Apple Intelligence Glow Different From a Rainbow Border

Four things, and only one of them is the colors.

The gradient is conic, not linear. A linear gradient slides across an element; a conic gradient sweeps around a center point. On a border that difference is everything, because a rotating conic gradient makes the light appear to travel around the edge of the card, while an animated linear gradient makes it look like a colored bar sliding past a window. Apple's effect reads as something circling the element. That reading comes entirely from the gradient function.

The color order is fixed: blue into violet into coral into amber, then back to blue. Sampled from the shipped animation at its brightest frame, those land near #0894FF, #C959DD, #FF2E54 and #FF9004. Apple has never published them as tokens, so treat the hex values as close rather than canonical. Reorder them and the effect stops reading as Apple Intelligence and starts reading as a generic rainbow, which is a surprisingly sharp cliff for something as loose as a hue sequence.

The ring is thin: 2px at 1x density. Wider ring, and the gradient starts competing with the card's content instead of framing it.

And the timing is slow enough to be legible: roughly 1.8 seconds for a full rotation. Fast enough to signal activity, slow enough that your eye can follow one color around the edge. Most imitations run at half a second and end up looking like a loading spinner, which is the exact meaning the pattern is trying not to have. If you want the reasoning behind the signal itself, the hub piece on designing for Apple Intelligence covers what the shimmer means and when it is supposed to appear at all. This tutorial is only about building it.

The Minimum Viable Ring

Here is the whole thing. Paste it into an empty file and it works.

@property --aigc-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.aigc-card {
  position: relative;
  z-index: 0;
  border-radius: 14px;
  background: #1c1c22;
  padding: 1rem 1.1rem;
}

.aigc-card::before {
  content: "";
  position: absolute;
  inset: -2px;
  padding: 2px;
  border-radius: 16px;
  background: conic-gradient(
    from var(--aigc-angle),
    #0894ff,
    #c959dd,
    #ff2e54,
    #ff9004,
    #0894ff
  );
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  animation: aigc-spin 1.8s linear infinite;
  pointer-events: none;
}

@keyframes aigc-spin {
  to { --aigc-angle: 360deg; }
}

Three details in there are load-bearing and easy to mistake for boilerplate.

inset: -2px with padding: 2px is the trick that makes the ring sit outside the card rather than eating into it. The pseudo-element stretches 2px past the card on every side, and the padding pulls its content box back to exactly the card's edge. That leaves a 2px frame between the border box and the content box, which is the only part the mask keeps.

The two linear-gradient(#000 0 0) layers are not decorative. The first is clipped to the content box, the second covers the whole padding box, and mask-composite: exclude subtracts the first from the second. What survives is the 2px frame. This is the standard way to get a gradient border in CSS, and the -webkit- duplicate with xor instead of exclude is still needed for Safari, which shipped the prefixed syntax first.

The border radius on the pseudo-element is 16px against the card's 14px. Concentric radii need to differ by the ring width or the corners look pinched. It is a 2px change nobody notices until it is missing.

Reading CSS is not the same as watching it. Here is the ring above, running:

Ring only

Summarize

Ring plus halo

Summarize

The same 2px conic-gradient ring at 1.8s per loop, on the right with a second blurred pseudo-element behind it. Both stop if your system prefers reduced motion.

Why @property Is the Part Everyone Misses

Custom properties are strings. That is the whole problem in one sentence.

Write --angle: 0deg in a normal declaration and the browser stores the characters 0deg. It has no idea that is an angle, so when an animation asks it to move from 0deg to 360deg there is nothing to interpolate between, and CSS falls back to its rule for non-animatable values: swap discretely at the halfway point. Your gradient sits still for 0.9 seconds, flips, and sits still again. Most people see a static ring and conclude that conic gradients cannot be animated.

@property fixes it by registering the property with a type:

@property --aigc-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

Now the engine knows 180deg lies between 0deg and 360deg, and the rotation runs smoothly. All three descriptors are required; leave out initial-value and the at-rule is dropped, which puts you right back where you started with no error to read.

inherits: false matters more than it looks. Registered properties inherit by default, and an inheriting --angle propagates into every descendant, so a card containing another ringed card ends up with both rings locked in step. Turning inheritance off keeps each ring's phase independent, which is what you want the moment two of them appear on screen together.

Support is broad now, all of Chrome, Edge, Safari 16.4+ and Firefox 128+, but the degradation is the interesting bit rather than the coverage. In a browser without @property, conic-gradient(from var(--aigc-angle), ...) still resolves, because the fallback for an unregistered custom property in that position is simply the declared value. You get a static multi-color ring. That is a perfectly good AI-active indicator; it just does not move. No feature query needed, no fallback branch to maintain. Degradation that requires no code from you is worth more than degradation you have to remember to write.

Adding the Halo Without Turning the Card Into a Lamp

The glow behind the ring is a second pseudo-element painting the same gradient, blurred, sitting a little further out:

.aigc-card::after {
  content: "";
  position: absolute;
  inset: -3px;
  padding: 8px;
  border-radius: 16px;
  background: conic-gradient(
    from var(--aigc-angle),
    #0894ff, #c959dd, #ff2e54, #ff9004, #0894ff
  );
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  filter: blur(12px);
  opacity: 0.6;
  animation: aigc-spin 1.8s linear infinite;
  pointer-events: none;
}

The padding: 8px gives the blur something to work with. A 2px band blurred at 12px mostly blurs itself into nothing; an 8px band holds enough color to read as light. The ratio worth keeping is a blur radius somewhere near one and a half times the band width. Past that the halo stops looking like light coming off an edge and starts looking like a colored rectangle behind a card, which is a different and much cheaper effect.

Both pseudo-elements run the same animation with the same duration, so they stay in phase. Give them different durations and the halo slowly drifts against the ring, which looks like a rendering bug even though nobody can say why.

The halo is also the first thing to cut. In a dense list, ten haloed rows turn into a wash of color with no edges. In a compact toolbar the glow bleeds over adjacent controls. Apple uses the halo on cards and full-width regions and drops it on small controls, which is a good rule to copy: if the element is smaller than roughly 200px on its long side, ship the ring alone.

Photo by Codioful on Unsplash

Ports: Tailwind, SwiftUI and Framer Motion

Same four colors, same 1.8 seconds, three environments.

Tailwind has no conic-gradient-with-custom-angle utility, and you should not try to force one through arbitrary values. Register the property and the keyframes in your CSS entry file, then expose the effect as one component class:

@layer components {
  .ai-ring {
    @apply relative z-0 rounded-[14px];
  }
  .ai-ring::before {
    content: "";
    @apply absolute -inset-0.5 rounded-2xl p-0.5 pointer-events-none;
    background: conic-gradient(from var(--aigc-angle), #0894ff, #c959dd, #ff2e54, #ff9004, #0894ff);
    mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    mask-composite: exclude;
    animation: aigc-spin 1.8s linear infinite;
  }
}

The mask and the gradient stay raw CSS because there is no utility that means either of them. Trying to express them as arbitrary values produces a class name longer than the rule it replaces.

SwiftUI has the effect built in, under a different name. AngularGradient is the conic gradient, and animating its angle is the same idea as animating --angle:

struct AIRing: View {
    @State private var phase: Double = 0
    private let colors: [Color] = [
        Color(red: 0.03, green: 0.58, blue: 1.00),
        Color(red: 0.79, green: 0.35, blue: 0.87),
        Color(red: 1.00, green: 0.18, blue: 0.33),
        Color(red: 1.00, green: 0.56, blue: 0.02),
    ]

    var body: some View {
        AngularGradient(colors: colors + [colors[0]], center: .center, angle: .degrees(phase))
            .mask(RoundedRectangle(cornerRadius: 14).stroke(lineWidth: 2))
            .onAppear {
                withAnimation(.linear(duration: 1.8).repeatForever(autoreverses: false)) {
                    phase = 360
                }
            }
    }
}

Note the repeated first color at the end of the array. A conic gradient wraps around to its start, so without that repeat you get a hard seam where amber meets blue. The same applies to the CSS version, and it is the second most common reason this effect looks wrong.

Framer Motion is the one case where you should probably not reach for JavaScript. The CSS animation runs off the main thread; a useAnimationFrame loop writing --angle every frame does not, and it will stutter under exactly the conditions an AI-processing indicator appears in, namely while your app is busy. Use Motion for the mount and unmount of the ringed element and let CSS own the rotation. Which side of that line a given animation belongs on is the whole subject of our CSS versus GSAP comparison, and this effect sits firmly on the CSS side.

Performance and Accessibility Before You Ship

The rotation itself is cheap. What is not cheap is filter: blur(), which forces the halo onto its own composited layer and repaints it as the gradient turns. One ring is free on any device made this decade. Twelve rings in a scrolling list is a different conversation, and it is the specific case where this effect goes from delightful to janky.

Three habits keep it honest:

  • Cap the count. If more than two or three elements can be AI-active at once, ring the container rather than each row. This is a design fix, not a performance hack, and it usually produces a better interface anyway.
  • Skip will-change. It is tempting here and mostly counterproductive. The animation already promotes the pseudo-element; adding will-change: transform on the card promotes another layer that never changes, and layer memory is the resource you run out of first on mobile.
  • Stop the animation when it stops meaning something. The ring is a process indicator. Leave it spinning after the result arrives and it becomes decoration, at which point it costs battery for nothing and trains users to ignore the one cue you wanted them to notice.

Then there is the guard nothing ships without:

@media (prefers-reduced-motion: reduce) {
  .aigc-card::before,
  .aigc-card::after {
    animation: none;
  }
}

The ring stays, in a static multi-color state, and still signals AI involvement. That is the right shape for a reduced-motion fallback: keep the information, drop the movement. Removing the border entirely would take the meaning with it. If motion sensitivity is a system you are building rather than a checkbox you are ticking, the micro-interactions guide works through when an animation is carrying information and when it is only carrying enthusiasm.

Contrast is the trap on light backgrounds. Amber #FF9004 against white lands around 2.2:1, well under the 3:1 that WCAG asks of a non-text interface element. On the near-black surfaces Apple uses this on, that is a non-issue. On a white card it means your ring is technically invisible to some users for a quarter of every rotation. Darken the amber and the coral for light mode, or accept that the ring is decorative there and carry the actual AI signal in text. Working out a second accent set for the opposite surface is the same problem as everything in the dark mode design guide, just with the two modes swapped.

One last thing, and it is not technical. Apple's Human Interface Guidelines ask third-party apps not to use this pattern for non-Apple AI. On the web nobody enforces that, but the reason behind it survives the platform change: the shimmer is a claim about whose model is running. If yours is OpenAI's or Anthropic's or your own, say so with your own signal. A single-hue breathing pulse on a slower cycle does the same job in about a third of the code:

.aigc-pulse {
  border: 1px solid #22c1a3;
  animation: aigc-breathe 2.8s ease-in-out infinite alternate;
}

@keyframes aigc-breathe {
  from { box-shadow: 0 0 4px 0 rgba(34, 193, 163, 0.2); }
  to   { box-shadow: 0 0 24px 4px rgba(34, 193, 163, 0.5); }
}

Your own signal

Drafting a reply

One brand hue, a 2.8 second breathing cycle, no rotation. Reads as AI activity without borrowing Apple's signal.

Slower, one color, unmistakably not Apple's. That is the point.

The web platform is closing in on this from the other direction, incidentally. Native gradient borders without the mask trick are among the Chrome 150 CSS motion features worth watching, and when that lands the mask-composite half of this recipe becomes a compatibility fallback rather than the main event. The @property half will still be the part people forget.

Frequently Asked Questions

How do you animate a conic gradient in CSS?
You do not animate the gradient itself, you animate the angle it starts from. Declare the angle as a typed custom property with @property, give it syntax angle and an initial value of 0deg, then use it in conic-gradient(from var(--angle), ...). A keyframe that sets the property to 360deg makes the browser interpolate the rotation on the compositor. Without the @property declaration the custom property is an untyped string, the browser has nothing to tween between, and the gradient jumps from 0 to 360 at the end of the animation instead of sweeping. That single missing declaration is the reason most copies of this effect sit perfectly still.
Why does my gradient border not animate?
Three causes, in the order they usually happen. First, the angle is a plain custom property rather than an @property-typed one, so it cannot be interpolated. Second, the gradient is on the element's background rather than on a pseudo-element that has been masked down to the border area, so the rotation is happening but you cannot see it behind the content. Third, border-image or a plain border sits on top of the pseudo-element and hides it. Check them in that order. If the ring paints but stays static in one browser and moves in another, you are looking at missing @property support rather than a bug in your CSS.
What is @property in CSS?
@property registers a custom property with a type, an inheritance rule, and an initial value, which turns it from a string the browser copies around into a value the browser understands. Registering --angle with syntax <angle> means the engine knows 90deg sits between 0deg and 180deg, so it can interpolate it in an animation or transition. It also means an invalid value falls back to the initial value instead of poisoning the whole declaration. Anything you want to animate through a custom property, an angle, a length, a color, a percentage, needs this registration first.
How do you make a glowing border in CSS?
Paint the same gradient twice on two pseudo-elements. The first is masked to the border area and gives you the crisp ring. The second sits behind the element, gets filter: blur(12px) and an opacity around 0.6, and gives you the halo. Both run the same rotation animation so the halo tracks the ring rather than drifting against it. Keep the blur radius roughly six times the ring width, because a halo much wider than that stops reading as light coming off an edge and starts reading as a colored rectangle behind a card.
Can you use the Apple Intelligence effect in your own app?
Technically yes, and Apple's Human Interface Guidelines ask you not to unless your app is actually calling Apple Intelligence APIs. The shimmer is a system signal meaning Apple's AI is doing work, and borrowing it for a third-party model tells users something untrue about whose AI they are trusting. The web has no Apple to enforce this, so the judgement is yours, and the honest version is to build your own signal. A single-hue pulse on a slower cycle reads as AI activity without impersonating anyone, and the last section of this tutorial has the code for it.