Glow

Rim Glow

Wrapper that rims its content with a currentColor glow inside and out, in dim, steady or breathing states. Can also cover the viewport as a presence overlay; breathing holds steady under reduced motion.

Free, MIT licenseOpen in Browse
import * as React from "react"
import { cn } from "cn"

// A rim of light around whatever it wraps, colored by currentColor. Also a
// full-viewport presence wash when `viewport` is set.

type RimGlowState = "dim" | "steady" | "breathing"

const STATE_OPACITY: Record<RimGlowState, number> = {
  dim: 0.55,
  steady: 1,
  breathing: 1,
}

// breathing drives its own opacity from the stylesheet, so the reduced-motion
// branch can substitute a steady value instead of collapsing into steady.
const BREATHE_CSS = `@keyframes rg-breathe{0%,100%{opacity:1}50%{opacity:0.7}}.rg-breathe{animation:rg-breathe 4.4s ease-in-out infinite}@media (prefers-reduced-motion: reduce){.rg-breathe{animation:none;opacity:0.8}}`

function mix(pct: number): string {
  const p = Math.round(Math.max(0, Math.min(100, pct)))
  return `color-mix(in oklab, currentColor ${p}%, transparent)`
}

/**
 * Wraps its children in a rim of light in the text color: a hairline with
 * glows inside and outside the edge. Set a `text-*` class for the color and
 * a `rounded-*` class for the corners; the rim follows both.
 *
 * `state="breathing"` pulses slowly and holds still at 80% under reduced
 * motion. `viewport` instead draws only the inner glow as a fixed
 * full-screen overlay that takes no pointer events, beside the children.
 */
function RimGlow({
  state = "steady",
  intensity = 1,
  spread = 24,
  viewport = false,
  className,
  children,
  ...props
}: React.ComponentProps<"div"> & {
  /** dim = lowered · steady = full · breathing = a slow 4.4s pulse */
  state?: RimGlowState
  /** 0–1.5 — multiplies every glow layer's alpha */
  intensity?: number
  /** Glow reach in px — how far the rim bleeds inward/outward */
  spread?: number
  /**
   * Draws the glow as a fixed full-screen overlay that takes no pointer
   * events. Children render in place, outside it, and `className` and other
   * props go on the overlay.
   */
  viewport?: boolean
}) {
  // A negative or non-finite blur invalidates the whole box-shadow.
  const reach = Number.isFinite(spread) ? Math.max(0, spread) : 24
  const boxShadow = [
    `inset 0 0 0 1px ${mix(85 * intensity)}`,
    `inset 0 0 ${Math.round(reach * 0.5)}px ${mix(45 * intensity)}`,
    `inset 0 0 ${Math.round(reach * 1.6)}px ${mix(18 * intensity)}`,
    ...(viewport
      ? []
      : [
          `0 0 ${Math.round(reach * 0.75)}px ${mix(30 * intensity)}`,
          `0 0 ${Math.round(reach * 2)}px ${mix(12 * intensity)}`,
        ]),
  ].join(", ")

  const overlay = (
    <span
      aria-hidden="true"
      className={cn(
        "pointer-events-none rounded-[inherit]",
        viewport ? "fixed inset-0" : "absolute inset-0",
        state === "breathing" && "rg-breathe"
      )}
      style={
        state === "breathing"
          ? { boxShadow }
          : { boxShadow, opacity: STATE_OPACITY[state] }
      }
    />
  )

  const breathe = state === "breathing" && (
    // href + precedence so React hoists one copy, not one per instance
    <style href="rim-glow-breathe" precedence="default">
      {BREATHE_CSS}
    </style>
  )

  // The viewport glow sits beside the children, not around them, so they
  // keep their place in the page and their pointer events.
  if (viewport)
    return (
      <>
        {children}
        <div
          data-slot="rim-glow"
          data-state={state}
          className={cn("pointer-events-none fixed inset-0 z-50", className)}
          {...props}
        >
          {breathe}
          {overlay}
        </div>
      </>
    )

  return (
    <div
      data-slot="rim-glow"
      data-state={state}
      className={cn("relative", className)}
      {...props}
    >
      {breathe}
      {overlay}
      {children}
    </div>
  )
}

export { RimGlow, type RimGlowState }

Installation

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

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/glow/rim-glow.tsx, as source you own and edit.

The example also uses card and item. Install them too:

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

Usage

import { cn } from "cn"

import { RimGlow } from "@/components/glow/rim-glow"
import { Card, CardContent } from "@/components/ui/card"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

// Every word on the page. Change copy here, not in the markup.
const COPY = {
  title: "Assistant",
  body: "Reading three files and drafting the migration plan…",
} as const

export default function RimGlowDemo({
  animate = false,
}: {
  // Breathe the rim; the card's play button turns it on.
  animate?: boolean
}) {
  return (
    <div className="dark relative isolate grid min-h-full w-full flex-1 place-items-center overflow-hidden bg-background text-foreground">
      {/* The rim takes the card's radius and glows in the current color. */}
      <RimGlow
        state={animate ? "breathing" : "steady"}
        spread={28}
        className="w-full max-w-md rounded-xl text-primary"
      >
        <Card>
          <CardContent>
            <Item>
              <ItemMedia>
                <span
                  aria-hidden="true"
                  className={cn(
                    "size-2 rounded-full bg-primary",
                    animate && "animate-pulse motion-reduce:animate-none"
                  )}
                />
              </ItemMedia>
              <ItemContent>
                <ItemTitle>{COPY.title}</ItemTitle>
                <ItemDescription>{COPY.body}</ItemDescription>
              </ItemContent>
            </Item>
          </CardContent>
        </Card>
      </RimGlow>
    </div>
  )
}

API reference

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.