Ring of light in three looks: a soft conic fade, an outline with a faint track and bright stroke, and a short traveling highlight. ArcLayer groups positioned arcs; spin is opt-in and stops for reduced motion.
import { useId, type ComponentProps, type CSSProperties } from "react"
import { cn } from "cn"
// One turn for every arc, backwards when ccw; reduced motion holds it still.
const ARC_CSS = `@keyframes arc-turn{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion: no-preference){
[data-slot="arc-layer"][data-animate] [data-slot="arc"],[data-slot="arc"][data-animate]{animation:arc-turn var(--arc-duration) linear infinite var(--arc-direction)}}`
const LOOKS = {
default: {
from: 150,
sweep: 150,
color: "oklch(0.80 0.06 350 / 0.65)",
width: 0,
seconds: 90,
},
outline: {
from: 0,
sweep: 108,
color: "oklch(0.842 0.061 196)",
width: 2.5,
seconds: 26,
},
traveling: {
from: 0,
sweep: 90,
color: "oklch(0.94 0.18 39)",
width: 1,
seconds: 6,
},
} as const
const finite = (n: number | undefined, fallback: number) =>
n !== undefined && Number.isFinite(n) ? n : fallback
/**
* A layer for `Arc` rings behind a section's content. It fills its positioned
* parent, clips any ring that runs past its edge, and ignores the pointer.
*/
function ArcLayer({
animate = false,
className,
...props
}: ComponentProps<"div"> & {
/**
* Turns every `Arc` inside, each at its own speed and direction. Reduced
* motion holds them still.
*/
animate?: boolean
}) {
return (
<div
data-slot="arc-layer"
data-animate={animate || undefined}
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden",
className
)}
{...props}
/>
)
}
/**
* A ring of light, lit along part of its circle, for the background behind a
* section. Put it in an `ArcLayer` or another positioned parent: `left` and
* `top` set its center, and a ring often sits partly past the frame's edge.
*
* It is hidden from assistive tech and ignores the pointer. When animated it
* makes one full turn per `duration`; reduced motion holds it still.
*/
function Arc({
variant = "default",
size = 900,
left = "50%",
top = "50%",
from,
sweep,
color,
fade = "oklch(0.60 0.05 340 / 0.25)",
track = "oklch(0.842 0.061 196 / 0.14)",
width,
trackWidth = 1.5,
turn = "cw",
animate = false,
duration,
className,
style,
...props
}: Omit<ComponentProps<"div">, "color"> & {
/**
* `default` is a thin, soft ring that brightens and fades along its lit
* part and lightens what is under it. `outline` is a bright stroke that
* fades toward its end, over a faint full ring. `traveling` is a single
* highlight that builds slowly and drops off at its end.
*/
variant?: keyof typeof LOOKS
/** Diameter in px, or any CSS length such as "80cqw". */
size?: number | string
/** The center's distance from the parent's left edge; any CSS length. */
left?: string
/** The center's distance from the parent's top edge; any CSS length. */
top?: string
/**
* Where the lit part starts, in degrees clockwise from 12 o'clock. On
* `traveling` it is where the lit part ends.
*/
from?: number
/** How far the lit part runs, in degrees from 0 to 360. */
sweep?: number
/** The color of the lit part. */
color?: string
/** The dimmer color the lit part fades through. Used by `default`. */
fade?: string
/** The color of the faint full ring. Used by `outline`. */
track?: string
/** Thickness of the lit stroke in px. Used by `outline` and `traveling`. */
width?: number
/** Thickness of the faint full ring in px. Used by `outline`. */
trackWidth?: number
/** Which way the ring turns when animated: clockwise or counterclockwise. */
turn?: "cw" | "ccw"
/** Turns this ring on its own. Reduced motion holds it still. */
animate?: boolean
/**
* Seconds per full turn. Defaults to 90 for `default` (120 when it turns
* counterclockwise), 26 for `outline` and 6 for `traveling`.
*/
duration?: number
}) {
const look = LOOKS[variant]
const gradientId = `arc-${useId().replace(/[^\w-]/g, "")}`
const start = finite(from, look.from)
const lit = Math.min(360, Math.max(0, finite(sweep, look.sweep)))
const ink = color ?? look.color
const stroke = Math.max(0, finite(width, look.width))
const seconds =
duration !== undefined && Number.isFinite(duration) && duration > 0
? duration
: variant === "default" && turn === "ccw"
? 120
: look.seconds
const diameter = typeof size === "number" ? `${size}px` : size
const paint: CSSProperties =
variant === "default"
? {
background: `conic-gradient(from ${start}deg, transparent 0deg, ${ink} ${lit * 0.37}deg, ${fade} ${lit * 0.63}deg, transparent ${lit}deg)`,
mask: "radial-gradient(closest-side in srgb, transparent calc(100% - 2px), oklch(0 0 0) calc(100% - 0.5px), transparent calc(100% + 1px))",
}
: variant === "traveling"
? {
padding: `${stroke}px`,
background: `conic-gradient(from ${start}deg, transparent 0deg, transparent ${360 - lit}deg, ${ink} ${360 - lit / 4}deg, transparent 360deg)`,
mask: "linear-gradient(in srgb, oklch(0 0 0) 0 0) content-box, linear-gradient(in srgb, oklch(0 0 0) 0 0)",
maskComposite: "exclude",
}
: { rotate: start ? `${start}deg` : undefined }
// The outline stroke fades along its own chord, clamped at a half turn.
const end = (Math.min(lit, 180) * Math.PI) / 180
return (
<div
data-slot="arc"
data-variant={variant}
data-turn={turn}
data-animate={animate || undefined}
aria-hidden="true"
className={cn(
"pointer-events-none absolute rounded-full",
variant === "default" && "mix-blend-screen",
className
)}
style={
{
left,
top,
width: size,
height: size,
margin: `calc(${diameter} / -2) 0 0 calc(${diameter} / -2)`,
"--arc-duration": `${seconds}s`,
"--arc-direction": turn === "ccw" ? "reverse" : "normal",
...paint,
...style,
} as CSSProperties
}
{...props}
>
<style href="arc" precedence="medium">
{ARC_CSS}
</style>
{variant === "outline" && (
<svg className="block size-full overflow-visible">
<defs>
<linearGradient
id={gradientId}
x1="0.5"
y1="0"
x2={0.5 + 0.5 * Math.sin(end)}
y2={0.5 - 0.5 * Math.cos(end)}
>
<stop offset="0" stopColor={ink} />
<stop offset="0.55" stopColor={ink} />
<stop offset="1" stopColor={ink} stopOpacity="0" />
</linearGradient>
</defs>
<circle
cx="50%"
cy="50%"
r="50%"
fill="none"
stroke={track}
strokeWidth={trackWidth}
/>
{lit > 0 && (
<circle
cx="50%"
cy="50%"
r="50%"
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={stroke}
strokeLinecap="round"
pathLength={360}
strokeDasharray={`${lit} ${360 - lit}`}
strokeDashoffset={90}
/>
)}
</svg>
)}
</div>
)
}
export { Arc, ArcLayer }
pnpm dlx shadcn add @01orbital/arcnpx shadcn add @01orbital/arcyarn shadcn add @01orbital/arcbunx --bun shadcn add @01orbital/arcFree under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/glow/arc.tsx, as source you own and edit.
import { Arc, ArcLayer } from "@/components/glow/arc"
export default function ArcDemo() {
return (
<div className="dark @container grid aspect-16/10 w-full place-items-center bg-background p-6 text-foreground">
<ArcLayer>
<Arc
size="min(72cqw, 36rem)"
left="46%"
from={225}
sweep={240}
color="oklch(0.88 0.08 65 / 0.9)"
fade="oklch(0.66 0.08 35 / 0.4)"
/>
<Arc
size="min(94cqw, 47rem)"
left="54%"
from={35}
sweep={230}
color="oklch(0.82 0.07 290 / 0.8)"
fade="oklch(0.60 0.06 320 / 0.3)"
turn="ccw"
/>
<Arc size="min(116cqw, 58rem)" left="46%" from={230} sweep={180} />
</ArcLayer>
</div>
)
}Arc is built from 2 parts. In the example they nest like this:
ArcLayer
└─ ArcArcLayer: A layer for Arc rings behind a section's content. It fills its positioned parent, clips any ring that runs past its edge, and ignores the pointer.Arc: A ring of light, lit along part of its circle, for the background behind a section. Put it in an ArcLayer or another positioned parent: left and top set its center, and a ring often sits partly past the frame's edge.A layer for Arc rings behind a section's content. It fills its positioned parent, clips any ring that runs past its edge, and ignores the pointer.
Prop
Type
It also accepts the HTML attributes of the element it renders.
A ring of light, lit along part of its circle, for the background behind a section. Put it in an ArcLayer or another positioned parent: left and top set its center, and a ring often sits partly past the frame's edge.
It is hidden from assistive tech and ignores the pointer. When animated it makes one full turn per duration; reduced motion holds it still.
Prop
Type
It also accepts the HTML attributes of the element it renders.
npm: cn. The CLI installs them for you.
No other registry pieces.
Compare
Every Glow component side by side: whether it follows the theme or stays dark, how many parts and custom props it has, and whether it is free. Open one for its preview, API and install.
Backlight Bloom
Wrapper that casts a blurred halo of light behind its children, as if the panel were lit from behind. Color, blur, intensity and spread are adjustable; an overflow-hidden ancestor clips it.