Accent beam
MotionA light travelling around a card's border — a conic gradient rotated with @property, on one composited layer.
Preview
Now booking
Two engagements open for the quarter.
The beam is decoration. The words are what tell you the state.
Controls
Notes
The rotation animates a registered @property angle, so it interpolates on the compositor. Animating a background-image string instead forces a repaint every frame and drops the card to single digits on a laptop.
The beam is a ::before pseudo-element, so it never enters the accessibility tree at all. It carries no meaning and should never be the only thing telling someone a card is selected.
Honours prefers-reduced-motion by holding the gradient still rather than removing it, so the card keeps its edge treatment either way.
Source
ShowHide
import { cn } from "@/lib/cn";
/**
* A light travelling around the border.
*
* The rotation animates a REGISTERED @property angle, which is what makes this
* cheap: the browser can interpolate a typed <angle> on the compositor.
* Animating the background-image string instead re-parses and repaints the
* gradient every frame, and drops a page of these into single digits.
*
* The beam is a ::before pseudo-element, so it never enters the accessibility
* tree at all. It is decoration and must never be the only thing saying a card
* is selected, live or in error.
*/
const css = `
@property --beam-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes beam-spin {
to { --beam-angle: 360deg; }
}
.beam { position: relative; isolation: isolate; }
.beam::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: var(--beam-width, 1.5px);
background: conic-gradient(
from var(--beam-angle),
transparent 0turn,
var(--beam-color, var(--accent)) 0.1turn,
transparent 0.26turn
);
/* Paint the padding ring only: two masks, minus the content box. */
-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: beam-spin var(--beam-duration, 6s) linear infinite;
pointer-events: none;
}
/* Hold the gradient still rather than removing it, so the card keeps its
edge treatment either way. */
@media (prefers-reduced-motion: reduce) {
.beam::before { animation: none; }
}
`;
const speeds = { slow: "11s", medium: "6s", fast: "3s" } as const;
const widths = { hairline: "1px", fine: "1.5px", bold: "3px" } as const;
const tones = { accent: "var(--accent)", ink: "var(--ink)" } as const;
export type BeamSpeed = keyof typeof speeds;
export type BeamThickness = keyof typeof widths;
export type BeamTone = keyof typeof tones;
export function Beam({
speed = "medium",
thickness = "fine",
tone = "accent",
className,
children,
}: {
speed?: BeamSpeed;
thickness?: BeamThickness;
tone?: BeamTone;
className?: string;
children: React.ReactNode;
}) {
return (
<>
<style href="beam-spin" precedence="default">
{css}
</style>
<div
className={cn("beam rounded-xl", className)}
style={
{
"--beam-duration": speeds[speed],
"--beam-width": widths[thickness],
"--beam-color": tones[tone],
} as React.CSSProperties
}
>
{children}
</div>
</>
);
}
export default function Demo({
speed = "medium",
thickness = "fine",
tone = "accent",
}: {
speed?: BeamSpeed;
thickness?: BeamThickness;
tone?: BeamTone;
}) {
return (
<Beam speed={speed} thickness={thickness} tone={tone} className="w-full max-w-sm">
<div className="rounded-xl bg-paper-raised p-7">
<p className="eyebrow">Now booking</p>
<p className="mt-4 text-lg text-ink">Two engagements open for the quarter.</p>
<p className="mt-3 text-[15px] leading-relaxed text-ink-muted">
The beam is decoration. The words are what tell you the state.
</p>
</div>
</Beam>
);
}
Next
ButtonNext step
Tell me where the business is losing time.
No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.