星光描边
New一束缓慢的光沿按钮或卡片边框环绕运行——纯 CSS 锥形渐变包裹器,可调光色、速度与描边厚度。「减少动效」偏好下静止为固定描边。
Pro plan
A slow light circles the card.
"use client";
import { StarBorder } from "@/components/motion/star-border";
export function StarBorderPreview() {
return (
<div className="flex flex-wrap items-center justify-center gap-10 p-6">
{/* startAngle puts this comet half a lap behind the card's, so two rings on
one screen don't travel in lockstep. */}
<StarBorder
className="rounded-full"
color="var(--accent)"
thickness={1.5}
startAngle={180}
>
<button
type="button"
className="rounded-full bg-neutral-950 px-6 py-2.5 text-sm font-semibold text-white"
>
✦ Get started
</button>
</StarBorder>
<StarBorder className="rounded-2xl" color="var(--accent)" speed={4}>
<div className="w-56 rounded-2xl border border-border bg-card p-5">
<div className="text-xs uppercase tracking-wider text-muted-foreground">Featured</div>
<h3 className="mt-2 text-lg font-semibold text-foreground">Pro plan</h3>
<p className="mt-2 text-sm text-muted-foreground">A slow light circles the card.</p>
</div>
</StarBorder>
</div>
);
}
// ui-lab-ten.vercel.app/components/motion/star-border
// Ported from motion-anything (nexu-io, Apache-2.0); upstream: reactbits.dev "Star Border", redistributed with permission.
// The two-layer comet head/tail, the mask-cut ring and the animated-angle
// approach are a clean-room reimplementation of techniques observed on
// unabyss.com; no upstream source was used.
import type { CSSProperties, ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface StarBorderProps {
/** Content the light ring wraps. Needs no background of its own — the ring is cut to the border band. */
children: ReactNode;
className?: string;
/** Color of the traveling light. Defaults to the current text color, so it follows whatever `text-*` class is set on the wrapper. */
color?: string;
/** Seconds for one full revolution around the border. */
speed?: number;
/** Border ring thickness in px. */
thickness?: number;
/**
* Where the comet sits at time zero, in degrees. Vary it across instances so
* several rings on one screen don't travel in lockstep.
*/
startAngle?: number;
}
/**
* Registering the angle as a custom property is what makes it animatable: the
* browser can then interpolate `--uilab-comet-angle` and only the gradient is
* repainted. The previous approach spun a huge element instead, which meant
* compositing a layer many times the size of the button.
*
* Where `@property` is unsupported the angle simply never advances, leaving the
* static ring — the same thing reduced motion asks for.
*/
const COMET_STYLES = `
@property --uilab-comet-angle{syntax:"<angle>";inherits:false;initial-value:0deg}
@keyframes uilab-comet-spin{to{--uilab-comet-angle:360deg}}
@media (prefers-reduced-motion:reduce){.uilab-comet-layer{animation:none!important}}
`;
/** Cuts a painted box down to just its padding band, leaving the middle clear. */
const RING_MASK =
"linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)";
function mix(color: string, percent: number): string {
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
/**
* A comet is a bright head with a long faint trail, so it takes two passes: a
* wide soft tail and a narrow bright core, both anchored to the same angle.
* One arc alone reads as a rotating smear.
*/
function cometGradient(color: string, layer: "tail" | "core"): string {
const from = "calc(var(--uilab-comet-angle) + var(--uilab-comet-start))";
// Stops run outward from 0deg and wrap back through 360deg, so the trail
// follows the head rather than leading it.
const stops =
layer === "tail"
? `${mix(color, 30)} 0deg, ${mix(color, 14)} 12deg, ${mix(color, 4)} 30deg, transparent 60deg, transparent 240deg, ${mix(color, 4)} 270deg, ${mix(color, 14)} 320deg, ${mix(color, 30)} 360deg`
: `${mix(color, 85)} 0deg, ${mix(color, 40)} 5deg, ${mix(color, 8)} 14deg, transparent 26deg, transparent 334deg, ${mix(color, 8)} 346deg, ${mix(color, 40)} 355deg, ${mix(color, 85)} 360deg`;
return `conic-gradient(from ${from}, ${stops})`;
}
/**
* A slow beam of light travels around an element's border — a quiet ambient
* accent for a single CTA or "featured" card.
*
* Pure CSS, no dependency: two conic-gradient layers (comet tail + head) are
* masked down to the border band, so the ring works over any background,
* including a transparent or blurred card. Reduced motion freezes the comet in
* place rather than hiding it.
*/
export function StarBorder({
children,
className,
color = "currentColor",
speed = 6,
thickness = 1,
startAngle = 0,
}: StarBorderProps) {
const layer: CSSProperties = {
padding: thickness,
animationDuration: `${speed}s`,
mask: RING_MASK,
WebkitMask: RING_MASK,
backgroundOrigin: "border-box",
backgroundClip: "border-box",
};
return (
<>
<style>{COMET_STYLES}</style>
<div
className={cn("relative isolate inline-block rounded-2xl", className)}
style={
{
padding: thickness,
"--uilab-comet-start": `${startAngle}deg`,
} as CSSProperties
}
>
<span
aria-hidden
className="uilab-comet-layer pointer-events-none absolute inset-0 rounded-[inherit] animate-[uilab-comet-spin_6s_linear_infinite]"
style={{ ...layer, background: cometGradient(color, "tail") }}
/>
<span
aria-hidden
className="uilab-comet-layer pointer-events-none absolute inset-0 rounded-[inherit] animate-[uilab-comet-spin_6s_linear_infinite]"
style={{ ...layer, background: cometGradient(color, "core") }}
/>
<div className="relative z-[2]">{children}</div>
</div>
</>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx tailwind-mergeAdd util file
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
// ui-lab-ten.vercel.app/components/motion/star-border
// Ported from motion-anything (nexu-io, Apache-2.0); upstream: reactbits.dev "Star Border", redistributed with permission.
// The two-layer comet head/tail, the mask-cut ring and the animated-angle
// approach are a clean-room reimplementation of techniques observed on
// unabyss.com; no upstream source was used.
import type { CSSProperties, ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface StarBorderProps {
/** Content the light ring wraps. Needs no background of its own — the ring is cut to the border band. */
children: ReactNode;
className?: string;
/** Color of the traveling light. Defaults to the current text color, so it follows whatever `text-*` class is set on the wrapper. */
color?: string;
/** Seconds for one full revolution around the border. */
speed?: number;
/** Border ring thickness in px. */
thickness?: number;
/**
* Where the comet sits at time zero, in degrees. Vary it across instances so
* several rings on one screen don't travel in lockstep.
*/
startAngle?: number;
}
/**
* Registering the angle as a custom property is what makes it animatable: the
* browser can then interpolate `--uilab-comet-angle` and only the gradient is
* repainted. The previous approach spun a huge element instead, which meant
* compositing a layer many times the size of the button.
*
* Where `@property` is unsupported the angle simply never advances, leaving the
* static ring — the same thing reduced motion asks for.
*/
const COMET_STYLES = `
@property --uilab-comet-angle{syntax:"<angle>";inherits:false;initial-value:0deg}
@keyframes uilab-comet-spin{to{--uilab-comet-angle:360deg}}
@media (prefers-reduced-motion:reduce){.uilab-comet-layer{animation:none!important}}
`;
/** Cuts a painted box down to just its padding band, leaving the middle clear. */
const RING_MASK =
"linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)";
function mix(color: string, percent: number): string {
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
/**
* A comet is a bright head with a long faint trail, so it takes two passes: a
* wide soft tail and a narrow bright core, both anchored to the same angle.
* One arc alone reads as a rotating smear.
*/
function cometGradient(color: string, layer: "tail" | "core"): string {
const from = "calc(var(--uilab-comet-angle) + var(--uilab-comet-start))";
// Stops run outward from 0deg and wrap back through 360deg, so the trail
// follows the head rather than leading it.
const stops =
layer === "tail"
? `${mix(color, 30)} 0deg, ${mix(color, 14)} 12deg, ${mix(color, 4)} 30deg, transparent 60deg, transparent 240deg, ${mix(color, 4)} 270deg, ${mix(color, 14)} 320deg, ${mix(color, 30)} 360deg`
: `${mix(color, 85)} 0deg, ${mix(color, 40)} 5deg, ${mix(color, 8)} 14deg, transparent 26deg, transparent 334deg, ${mix(color, 8)} 346deg, ${mix(color, 40)} 355deg, ${mix(color, 85)} 360deg`;
return `conic-gradient(from ${from}, ${stops})`;
}
/**
* A slow beam of light travels around an element's border — a quiet ambient
* accent for a single CTA or "featured" card.
*
* Pure CSS, no dependency: two conic-gradient layers (comet tail + head) are
* masked down to the border band, so the ring works over any background,
* including a transparent or blurred card. Reduced motion freezes the comet in
* place rather than hiding it.
*/
export function StarBorder({
children,
className,
color = "currentColor",
speed = 6,
thickness = 1,
startAngle = 0,
}: StarBorderProps) {
const layer: CSSProperties = {
padding: thickness,
animationDuration: `${speed}s`,
mask: RING_MASK,
WebkitMask: RING_MASK,
backgroundOrigin: "border-box",
backgroundClip: "border-box",
};
return (
<>
<style>{COMET_STYLES}</style>
<div
className={cn("relative isolate inline-block rounded-2xl", className)}
style={
{
padding: thickness,
"--uilab-comet-start": `${startAngle}deg`,
} as CSSProperties
}
>
<span
aria-hidden
className="uilab-comet-layer pointer-events-none absolute inset-0 rounded-[inherit] animate-[uilab-comet-spin_6s_linear_infinite]"
style={{ ...layer, background: cometGradient(color, "tail") }}
/>
<span
aria-hidden
className="uilab-comet-layer pointer-events-none absolute inset-0 rounded-[inherit] animate-[uilab-comet-spin_6s_linear_infinite]"
style={{ ...layer, background: cometGradient(color, "core") }}
/>
<div className="relative z-[2]">{children}</div>
</div>
</>
);
}
API 参考
childrenReactNodeContent the light ring wraps. Needs no background of its own — the ring is cut to the border band.
—className?string—color?stringColor of the traveling light. Defaults to the current text color, so it follows whatever `text-*` class is set on the wrapper.
currentColorspeed?numberSeconds for one full revolution around the border.
6thickness?numberBorder ring thickness in px.
1startAngle?numberWhere the comet sits at time zero, in degrees. Vary it across instances so several rings on one screen don't travel in lockstep.
0Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.