Browse the docs
CreativeNo. 85 of 109
Animated Border
A border with a light travelling around it, paused off screen.
Installation
pnpm dlx shadcn@latest add @corsair-ui/animated-borderNo setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.
- Also adds
- utilsuse-in-view
- npm
clsxtailwind-merge
Examples
Default
Default
The primary colour, six seconds a lap.
Colour, width and speed
Slow brass
duration={10}
Pill, 2px, 3s
API
| Prop | Type | Default |
|---|---|---|
durationSeconds for one lap. | number | 6 |
colorAny CSS colour. | string | var(--primary) |
widthBorder width, in px. | number | 1 |
contentClassNameClasses for the surface inside. | string | — |
Accessibility
- The light is aria-hidden and pauses off screen.
- With prefers-reduced-motion it holds still and shows the finished state.
Compatibility
- Tailwind CSS
- 3.4 and 4, both checked in CI
- React
- 19
- Rendering
- Client component ("use client")
- Motion
- Respects prefers-reduced-motion
Source
1 file, installed as source you own
animated-border.tsxView on GitHub"use client";
import { useMemo, type ComponentProps, type Ref } from "react";
import { useInView } from "@/registry/default/hooks/use-in-view";
import { cn } from "@/registry/default/lib/utils";
function mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
};
}
interface AnimatedBorderProps extends ComponentProps<"div"> {
/** Seconds for the light to go around once. */
duration?: number;
/** Colour of the light: any CSS colour, including theme variables. */
color?: string;
/** Border width, in px. */
width?: number;
/** Classes for the surface inside the border, where the content sits. */
contentClassName?: string;
}
/**
* A border with a light travelling around it. The light is one rotating
* layer (transform only, so the compositor does the work), it pauses while
* off screen, and it holds still with `prefers-reduced-motion`. Set the
* radius on the component; the surface inside follows it.
*
* The layer is sized from the width, which covers anything up to about
* 1.7 times taller than it is wide.
*/
function AnimatedBorder({
duration = 6,
color = "var(--primary)",
width = 1,
className,
contentClassName,
style,
children,
ref,
...props
}: AnimatedBorderProps) {
const [observe, inView] = useInView<HTMLDivElement>({ rootMargin: "100px" });
const mergedRef = useMemo(() => mergeRefs(ref, observe), [ref, observe]);
return (
<div
ref={mergedRef}
data-slot="animated-border"
className={cn("bg-border relative isolate overflow-hidden rounded-xl", className)}
style={{ padding: width, ...style }}
{...props}
>
{/* Centered with margins rather than a transform, which the spin would overwrite. */}
<span
aria-hidden="true"
data-slot="animated-border-light"
className="pointer-events-none absolute top-1/2 left-1/2 -mt-[100%] -ml-[100%] aspect-square w-[200%] animate-spin motion-reduce:animate-none"
style={{
background: `conic-gradient(from 0deg, transparent 0 62%, ${color} 82%, transparent 96%)`,
animationDuration: `${duration}s`,
animationPlayState: inView ? "running" : "paused",
}}
/>
<div
data-slot="animated-border-content"
className={cn(
"bg-card text-card-foreground relative h-full rounded-[inherit]",
contentClassName
)}
>
{children}
</div>
</div>
);
}
export { AnimatedBorder, type AnimatedBorderProps };