Glow

Arc

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.

Free, MIT licenseOpen in Browse
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 }

Installation

pnpm dlx shadcn add @01orbital/arc
npx shadcn add @01orbital/arc
yarn shadcn add @01orbital/arc
bunx --bun shadcn add @01orbital/arc

Free 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.

Usage

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>
  )
}

Anatomy

Arc is built from 2 parts. In the example they nest like this:

ArcLayer
└─ Arc
  • ArcLayer: 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.

API reference

ArcLayer

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.

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.

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.

Dependencies

npm: cn. The CLI installs them for you.

No other registry pieces.