Conic @property Border
A rounded box wearing an animated conic-gradient border. Two bright stops travel around the ring in opposite quadrants, so the ring reads as rotating rather than blinking. The whole thing is one element and one keyframe — the only moving part is a registered custom property holding an angle, which the browser interpolates for you.
Conic @property Border
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/conic-property-border.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
One file, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Content rendered inside the ring. The element is inline-flex, centred. |
| durationMs | number | = 4000 | Milliseconds for one full revolution. |
| thickness | string | = '1.5px' | Ring thickness. Becomes the transparent border width. |
| radius | string | = '9999px' | Corner radius. The gradient follows it, unlike border-image. |
| glow | string | — | Colour of the two travelling highlights. Defaults to currentColor. |
| rest | string | — | Colour of the dim ring between highlights. Defaults to currentColor at 10%. |
| fill | string | — | Interior fill. Must be opaque or the gradient shows through the middle. |
| paused | boolean | = false | Sets animation-play-state: paused, freezing the ring at its current angle. |
| className | string | — | Merged onto the ring element via cn. Use it for padding and typography. |
How it works
The technique in order, not the code in order.
@property --kx-conic-angle { syntax: "<angle>"; inherits: false; initial-value: 0deg }registers the custom property so the browser knows its value is an angle. An unregistered custom property is an untyped token stream: a keyframe from0degto360deghas no interpolation rule and the value flips at the 50% mark instead of sweeping.A single keyframe block,
@keyframes kx-conic-spin { to { --kx-conic-angle: 360deg } }, drives that property from itsinitial-valueto a full turn over--kx-conic-durationwithlineartiming. Linear is required: any ease makes the highlights visibly hesitate once per revolution.The element carries two backgrounds.
linear-gradient(fill, fill) padding-boxpaints the interior flat;conic-gradient(from var(--kx-conic-angle), …) border-boxpaints under the border. Withborder: 1.5px solid transparent, the conic layer survives the clip only in the ring, which is how the gradient followsborder-radius—border-imagecannot round corners.The gradient has six stops with the glow colour at 55deg and 305deg and the rest colour everywhere else. Two highlights roughly opposite each other read as rotation; a single highlight reads as a light blinking on and off once per cycle.
inherits: falseon the registration stops descendants from picking up the animating value. With inheritance on, every element in the subtree re-resolves its computed style on every frame of the animation, which turns a one-element repaint into a whole-subtree style recalculation.Every colour is read through
var(--name, fallback), so the component renders correctly with no props and can be re-themed by setting--conic-glowor--conic-fillon any ancestor.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| glow stop positions | = 55deg and 305deg | Two highlights, not one, and not diametrically opposite. 250 degrees apart means one is entering the top edge as the other leaves the bottom, so there is always motion somewhere on the ring. At 180 degrees apart the pair reads as a symmetric pulse; with one stop it reads as a blinking dot. |
| durationMs | = 4000 | One revolution. Below ~2500 ms the ring reads as a loading spinner and demands attention; above ~7000 ms the highlight barely appears to move and the border looks like a static gradient someone got wrong. |
| thickness | = 1.5px | Sub-pixel on a 1x display, which is what keeps it a hairline rather than a stripe. At 1px the highlight is too thin to read colour; at 3px or more the conic banding between stops becomes visible as facets. |
| rest colour alpha | = currentColor at 10% | The dim majority of the ring. At 0% the border disappears between highlights and the box appears to lose its outline; above ~20% the contrast against the glow collapses and the rotation stops reading. |
| reduced-motion parked angle | = 132deg | Puts one highlight past the top-left shoulder rather than exactly on an edge or a corner. At the initial-value of 0deg the highlight sits dead centre on the right edge, which looks like a rendering artefact rather than a decision. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
A @media (prefers-reduced-motion: reduce) block sets animation: none and pins --kx-conic-angle to 132deg. The gradient border stays — same colours, same thickness — it simply does not revolve. Nothing is removed from the layout and no size changes.
Nothing to clean up. There is no listener, no timer and no animation frame; unmounting the element removes the only thing that was running. The hoisted <style> is reference-counted by React and removed when the last instance unmounts.
Two bright arcs travel continuously around the border and pass the corners without pausing or jumping — in particular, nothing snaps at the halfway point of the cycle. The interior stays fully opaque. Set paused and the arcs freeze where they are. Enable reduced motion at the OS level and the ring is still coloured but completely still.
Pitfalls
- Animating the custom property without registering it with
@property. The animation runs —getComputedStyleeven reports it — but the gradient snaps from its start angle to its end angle at the halfway point, because an unregistered custom property interpolates as a discrete token stream. This is the single most common way this effect is reported "broken". - Writing
border: 1.5px solidwith no colour, or omitting the border entirely. With no border there is no border-box area outside the padding box, the conic layer is fully covered by the fill layer, and the ring is invisible. The border must exist and must be transparent. - Putting the fill layer second in the
backgroundlist. Backgrounds paint first-listed on top; with the order reversed the flat fill is painted underneath and the conic gradient covers the entire box, not just the ring. - Leaving
inheritsat its default oftrue. Descendants then inherit an animating value, so the browser recomputes styles for the whole subtree every frame. On a card with 40 children this shows up as a steady 3-5 ms of style recalculation per frame in a performance profile. - Assuming the animation is compositor-driven and reaching for
will-change: transform. It is not: animating a registered property repaints the element background on the main thread every frame. That is cheap on a button and expensive on a full-width hero — scale the element, not the hint. - Using a semi-transparent interior fill. The conic layer extends under the whole border box, so anything less than opaque lets the gradient bleed through the middle of the card.
Notes
@propertyneeds Safari 16.4+, Chrome 85+, Firefox 128+. Where it is unsupported the@propertyrule is dropped, the keyframe cannot animate the custom property, andfrom var(--kx-conic-angle)falls back to the guaranteed-invalid initial value — the conic gradient still paints from 0deg. The degradation is a static two-highlight ring, which is an acceptable border on its own.- The CSS ships inside the component as a
<style href precedence>element. React 19 hoists it into<head>and de-duplicates byhref, so any number of instances contribute one rule set and one@propertyregistration. - The class name is prefixed
kx-rather than scoped per instance on purpose:@propertyregistration is document-global, so per-instance scoping would buy nothing and cost a style element per mount.