ボタン
BorderBeam
CSS
ボーダーを光のビームが一周する、流行りのフレーム。ボタン・リンク・告知チップ・カードや入力欄を包むラッパーで、ビーム / レインボー / パルスの 3 種とグロー・シマーを備えた純 CSS 実装。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約29 KB)。
# BorderBeam をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: BorderBeam
- 説明: ボーダーを光のビームが一周する、流行りのフレーム。ボタン・リンク・告知チップ・カードや入力欄を包むラッパーで、ビーム / レインボー / パルスの 3 種とグロー・シマーを備えた純 CSS 実装。
- 構成ファイル: `components/border-beam/index.tsx`, `components/border-beam/BorderBeam.module.css`
## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。
## 手順
### 1. 依存パッケージを追加する
追加の npm パッケージは不要です(React のみ)。
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/border-beam/index.tsx`
```tsx
"use client";
import {
useCallback,
useEffect,
useRef,
type CSSProperties,
type HTMLAttributes,
type ReactNode,
} from "react";
import styles from "./BorderBeam.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type BorderBeamVariant = "beam" | "rainbow" | "pulse";
type BorderBeamOwnProps = {
/** Rendered element. "div" is a pure frame (no padding, no button styles). */
as?: "button" | "a" | "div";
children?: ReactNode;
/** Tail color of the beam (start of the gradient). Ignored by "rainbow". */
colorFrom?: string;
/** Head color of the beam (end of the gradient). Ignored by "rainbow". */
colorTo?: string;
/** Seconds per lap around the border. */
duration?: number;
/** Ring thickness in px. */
borderWidth?: number;
/** Corner radius in px. The default 999 makes a pill. */
radius?: number;
/**
* "beam": one comet with a tail. "rainbow": a continuous full-spectrum
* ring. "pulse": a two-tone ring whose glow breathes.
*/
variant?: BorderBeamVariant;
/** Blurred copy of the beam behind the element. */
glow?: boolean;
/** Glow strength, 0–1 (opacity, and the blur grows with it). */
glowIntensity?: number;
/** Diagonal light sweep across the inner surface. */
shimmer?: boolean;
className?: string;
style?: CSSProperties;
/** button only. Defaults to "button" so it never submits a form by accident. */
type?: "button" | "submit" | "reset";
/** button: native disabled. a: drops href and sets aria-disabled. */
disabled?: boolean;
/** a only. */
href?: string;
/** a only. */
target?: string;
/** a only. */
rel?: string;
};
export type BorderBeamProps = BorderBeamOwnProps &
Omit<HTMLAttributes<HTMLElement>, keyof BorderBeamOwnProps | "color">;
const DEFAULT_FROM = "#8b5cf6";
const DEFAULT_TO = "#22d3ee";
const DEFAULT_DURATION = 4;
const DEFAULT_WIDTH = 1.5;
const DEFAULT_RADIUS = 999;
const DEFAULT_GLOW_INTENSITY = 0.6;
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
/**
* Same browsers ship `@property` and `CSS.registerProperty`, and there is
* no `@supports` test for the at-rule — so this is the feature check.
* Evaluated lazily: `CSS` doesn't exist during SSR.
*/
let propertySupport: boolean | undefined;
function supportsRegisteredProperties() {
if (propertySupport === undefined) {
propertySupport =
typeof CSS !== "undefined" && "registerProperty" in CSS;
}
return propertySupport;
}
/**
* A light beam that travels around the border of whatever it wraps — a
* CTA, a link, an announcement chip or a card / input frame. Pure CSS:
* a conic gradient whose angle is animated through a registered custom
* property, a blurred copy behind it for the glow, and an opaque inner
* surface. Pauses offscreen and honors prefers-reduced-motion.
*/
export function BorderBeam({
as = "button",
children,
colorFrom,
colorTo,
duration,
borderWidth,
radius,
variant = "beam",
glow = true,
glowIntensity,
shimmer = false,
className,
style,
type = "button",
disabled = false,
href,
target,
rel,
...rest
}: BorderBeamProps) {
const rootRef = useRef<HTMLElement | null>(null);
// Callback ref: one function fits <button>, <a> and <div> alike.
const setRoot = useCallback((node: HTMLElement | null) => {
rootRef.current = node;
}, []);
// Fallback flag and offscreen pause are written straight to data-*
// attributes: no re-render, and the SSR markup stays identical.
useEffect(() => {
const node = rootRef.current;
if (!node) return;
if (!supportsRegisteredProperties()) node.dataset.bbFallback = "";
if (typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) delete node.dataset.paused;
else node.dataset.paused = "";
});
observer.observe(node);
return () => observer.disconnect();
}, []);
// The CSS module carries the defaults; custom properties are only set
// for non-default values so the default render matches the stylesheet.
const vars: CSSVars = {};
if (colorFrom && colorFrom.toLowerCase() !== DEFAULT_FROM) {
vars["--bb-from"] = colorFrom;
}
if (colorTo && colorTo.toLowerCase() !== DEFAULT_TO) {
vars["--bb-to"] = colorTo;
}
if (duration !== undefined && duration !== DEFAULT_DURATION) {
vars["--bb-duration"] = `${Math.max(0.2, duration)}s`;
}
if (borderWidth !== undefined && borderWidth !== DEFAULT_WIDTH) {
vars["--bb-width"] = `${Math.max(0, borderWidth)}px`;
}
if (radius !== undefined && radius !== DEFAULT_RADIUS) {
vars["--bb-radius"] = `${Math.max(0, radius)}px`;
}
if (glowIntensity !== undefined && glowIntensity !== DEFAULT_GLOW_INTENSITY) {
const k = clamp01(glowIntensity);
vars["--bb-glow-opacity"] = k;
// A stronger glow also spreads further, not just brighter.
vars["--bb-glow-blur"] = `${Math.round(6 + k * 14)}px`;
}
const shared = {
...rest,
ref: setRoot,
className: className ? `${styles.root} ${className}` : styles.root,
style: { ...vars, ...style },
"data-border-beam": "",
"data-as": as,
"data-variant": variant,
"data-disabled": disabled ? "" : undefined,
};
const inner = (
<>
{glow ? <span className={styles.glow} aria-hidden /> : null}
<span className={styles.beam} aria-hidden />
<span className={styles.surface}>
{shimmer ? <span className={styles.shimmer} aria-hidden /> : null}
<span className={styles.content}>{children}</span>
</span>
</>
);
// Branch per tag rather than a dynamic `Tag`: href / type / disabled
// only type-check on their own element.
if (as === "a") {
return (
<a
{...shared}
href={disabled ? undefined : href}
target={target}
rel={rel ?? (target === "_blank" ? "noopener noreferrer" : undefined)}
aria-disabled={disabled || undefined}
tabIndex={disabled ? -1 : shared.tabIndex}
>
{inner}
</a>
);
}
if (as === "div") {
return <div {...shared}>{inner}</div>;
}
return (
<button {...shared} type={type} disabled={disabled}>
{inner}
</button>
);
}
export default BorderBeam;
```
#### `components/border-beam/BorderBeam.module.css`
```css
/* ==========================================================================
Border beam — a light beam that travels around an element's border
Layer stack (back → front), all inside one isolated root:
.glow blurred copy of the beam, spills outside the box (z -1)
.beam conic gradient + faint track, clipped to the radius
.surface opaque inner surface inset by the border width — the strip
of .beam left visible around it IS the ring
.shimmer optional light sweep across the surface
The ring comes from the inset opaque surface rather than a mask:
identical on an opaque surface, and it sidesteps Lightning CSS's
prefix handling of mask / mask-composite.
========================================================================== */
/* Prefixed name: @property registrations are page-global, so a bare
`--angle` could collide with another component's registration. */
@property --bb-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.root {
/* Spec values. The component sets these inline only for non-default
props, so the default render is exactly this stylesheet. */
--bb-from: #8b5cf6;
--bb-to: #22d3ee;
--bb-duration: 4s;
--bb-width: 1.5px;
--bb-radius: 999px;
--bb-glow-opacity: 0.6;
--bb-glow-blur: 14px;
/* Faint static border the beam runs over. */
--bb-track: rgba(255, 255, 255, 0.1);
/* Bright leading edge of the comet: the end color pushed toward white. */
--bb-head: color-mix(in srgb, var(--bb-to) 35%, white);
/* Comet: a long transparent run, then the tail ramps up and ends on the
bright head. Conic gradients don't wrap 360° → 0°, so ending on the
head and restarting on transparent gives a crisp leading edge. */
--bb-stops:
transparent 0%, transparent 62%,
color-mix(in srgb, var(--bb-from) 0%, transparent) 64%,
var(--bb-from) 84%, var(--bb-to) 97%, var(--bb-head) 100%;
/* Tunables that are deliberately NOT declared here — padding, font size
and surface color are read as var(--x, default) so a consumer (or a
Tailwind arbitrary property, which sits in a layer and could never
beat this unlayered file) can set them on the root:
--bb-padding, --bb-font-size, --bb-surface */
position: relative;
isolation: isolate; /* keeps the z -1 glow above the page background */
display: inline-flex;
align-items: stretch;
box-sizing: border-box;
padding: var(--bb-width);
border: 0;
border-radius: var(--bb-radius);
background: none;
color: #fff;
text-decoration: none;
vertical-align: middle;
-webkit-tap-highlight-color: transparent;
transition: transform 160ms ease;
}
/* Interactive tags behave like a CTA: button font, padding and cursor. */
.root[data-as="button"],
.root[data-as="a"] {
margin: 0;
font-family:
"Inter", -apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
font-size: var(--bb-font-size, 14px);
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.01em;
cursor: pointer;
user-select: none;
}
.root[data-variant="rainbow"] {
/* Full spectrum, last stop = first stop so there is no seam. */
--bb-stops:
#ff4d6d, #ff9f43, #ffd93d, #4ade80, #22d3ee, #6366f1, #c084fc, #ff4d6d;
}
.root[data-variant="pulse"] {
/* A full two-tone ring; the life comes from the breathing glow. */
--bb-stops: var(--bb-from), var(--bb-to), var(--bb-from);
}
/* --- Beam layer -------------------------------------------------------- */
.beam,
.glow {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
overflow: hidden;
pointer-events: none;
/* The gradient lives on the animated element itself: declared on the
root, var(--bb-angle) would resolve there and never move. */
background-image: conic-gradient(from var(--bb-angle), var(--bb-stops));
animation: bb-spin var(--bb-duration) linear infinite;
}
.beam {
background-color: var(--bb-track);
/* Lets the fallback pseudo size itself to cover the diagonal. */
container-type: size;
}
.glow {
z-index: -1;
opacity: var(--bb-glow-opacity);
filter: blur(var(--bb-glow-blur));
transition: opacity 240ms ease;
}
.root[data-variant="pulse"] .glow {
animation:
bb-spin var(--bb-duration) linear infinite,
bb-breathe calc(var(--bb-duration) * 0.6) ease-in-out infinite;
}
@keyframes bb-spin {
to {
--bb-angle: 360deg;
}
}
@keyframes bb-breathe {
0%,
100% {
opacity: calc(var(--bb-glow-opacity) * 0.35);
transform: scale(0.97);
}
50% {
opacity: var(--bb-glow-opacity);
transform: scale(1.03);
}
}
/* --- Fallback: no @property ------------------------------------------- */
/* Without registration the angle can't interpolate (it would flip once
per cycle). The component then sets data-bb-fallback and the gradient
moves to a square pseudo-element rotated with transform instead. */
.root[data-bb-fallback] .beam,
.root[data-bb-fallback] .glow {
background-image: none;
animation: none;
}
.root[data-bb-fallback][data-variant="pulse"] .glow {
animation: bb-breathe calc(var(--bb-duration) * 0.6) ease-in-out infinite;
}
.root[data-bb-fallback] .glow {
container-type: size;
}
.root[data-bb-fallback] .beam::before,
.root[data-bb-fallback] .glow::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
/* width + height ≥ the diagonal, so the square covers every corner at
every angle. (A plain 200% fallback line would be collapsed away by
Lightning CSS; container units predate @property everywhere that
matters here anyway.) */
width: calc(100cqw + 100cqh);
aspect-ratio: 1;
background-image: conic-gradient(from 0deg, var(--bb-stops));
animation: bb-rotate var(--bb-duration) linear infinite;
}
@keyframes bb-rotate {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
/* --- Surface ------------------------------------------------------------ */
.surface {
position: relative;
z-index: 1;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
min-width: 0;
box-sizing: border-box;
padding: var(--bb-padding, 0);
border-radius: max(0px, calc(var(--bb-radius) - var(--bb-width)));
overflow: hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0) 55%),
var(--bb-surface, #0c0c10);
/* Top-edge highlight: reads as a lit glass lip without a hard border. */
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.root[data-as="button"] .surface,
.root[data-as="a"] .surface {
padding: var(--bb-padding, 0.72em 1.5em);
white-space: nowrap;
}
/* Fine grain keeps the dark surface from looking flat / banded. */
.surface::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
opacity: 0.07;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}
.content {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: inherit;
min-width: 0;
}
.root[data-as="div"] .content {
display: block;
flex: 1;
}
/* --- Shimmer ------------------------------------------------------------ */
.shimmer {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: linear-gradient(
105deg,
transparent 30%,
color-mix(in srgb, var(--bb-to) 22%, rgba(255, 255, 255, 0.14)) 50%,
transparent 70%
);
transform: translateX(-120%);
/* Sweep, then rest — a constant sweep reads as a loading skeleton. */
animation: bb-shimmer calc(var(--bb-duration) * 0.9 + 1.2s) ease-in-out infinite;
}
@keyframes bb-shimmer {
0% {
transform: translateX(-120%);
}
55%,
100% {
transform: translateX(120%);
}
}
/* --- Interaction ---------------------------------------------------------- */
@media (hover: hover) {
.root[data-as="button"]:hover,
.root[data-as="a"]:hover {
transform: translateY(-1px);
}
.root:hover .glow {
opacity: min(1, calc(var(--bb-glow-opacity) * 1.5));
}
}
.root:focus-within .glow {
opacity: min(1, calc(var(--bb-glow-opacity) * 1.5));
}
.root[data-as="button"]:active,
.root[data-as="a"]:active {
transform: scale(0.98);
}
.root:focus-visible,
.root[data-as="div"]:has(:focus-visible) {
/* A framed input / card shows the ring when keyboard focus is inside. */
outline: 2px solid var(--bb-to);
outline-offset: 3px;
}
.root[data-disabled],
.root[data-disabled]:is(:hover, :active) {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Offscreen (IntersectionObserver) or disabled: stop repainting. */
.root[data-paused] .beam,
.root[data-paused] .glow,
.root[data-paused] .beam::before,
.root[data-paused] .glow::before,
.root[data-paused] .shimmer,
.root[data-disabled] .beam,
.root[data-disabled] .glow,
.root[data-disabled] .beam::before,
.root[data-disabled] .glow::before,
.root[data-disabled] .shimmer {
animation-play-state: paused;
}
/* --- Reduced motion -------------------------------------------------------- */
/* Static but intentional: the comet becomes a full two-tone ring with a
still glow, so nothing looks frozen mid-sweep. */
@media (prefers-reduced-motion: reduce) {
.root {
transition: none;
}
.root:not([data-variant="rainbow"]) {
--bb-stops: var(--bb-from), var(--bb-to), var(--bb-from);
}
.beam,
.glow,
.root[data-variant="pulse"] .glow,
.beam::before,
.glow::before,
.root[data-bb-fallback] .beam::before,
.root[data-bb-fallback] .glow::before,
.root[data-bb-fallback][data-variant="pulse"] .glow {
animation: none;
}
.beam,
.glow {
--bb-angle: 135deg;
}
.root[data-bb-fallback] .beam::before,
.root[data-bb-fallback] .glow::before {
transform: translate(-50%, -50%) rotate(135deg);
}
.shimmer {
display: none;
}
.root[data-as="button"]:hover,
.root[data-as="a"]:hover,
.root[data-as="button"]:active,
.root[data-as="a"]:active {
transform: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/border-beam.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { BorderBeam } from "@/components/border-beam";
// TODO: <BorderBeam …>新機能を試す ✦</BorderBeam> のようにラベルを children として渡し、onClick で処理をつないでください
// TODO: リンクにするなら as="a" href="…"、カードや入力欄の枠にするなら as="div" を指定してください
<BorderBeam
variant="beam"
colorFrom="#8b5cf6"
colorTo="#22d3ee"
borderWidth={1.5}
radius={999}
glow={true}
glowIntensity={0.6}
shimmer={false}
duration={4}
/>
```
### 4. 組み込み手順
1. 使いたい場所で `<BorderBeam onClick={…}>新機能を試す ✦</BorderBeam>` のようにラベルを `children` に渡す。既定は `<button type="button">` なので、フォーム送信に使うときだけ `type="submit"` を指定する。
2. リンクにするなら `as="a" href="…"`(`target="_blank"` なら `rel="noopener noreferrer"` が自動で付く)。カードや入力欄の「枠」にするなら `as="div"` — この場合は余白もボタン用の文字スタイルも付かないので、中身側で padding を持つ。
3. サイズや面の色は CSS 変数で上書きする: `--bb-padding`(例 `0.85em 1.9em`)、`--bb-font-size`(例 `16px`)、`--bb-surface`(内側の面の色、既定 `#0c0c10`)。Tailwind なら `className="[--bb-font-size:16px] [--bb-padding:0.85em_1.9em]"`。
4. 背景は暗いページ前提(`#0a0a0a` 前後)。明るい背景に置く場合は `--bb-surface` を面に合う色にし、`glowIntensity` を下げる。
5. 依存パッケージは無し(React と CSS Modules のみ)。`npm run build` が通ることを確認し、Chrome / Safari / Firefox でビームが滑らかに回ること、OS の「視差効果を減らす」をオンにすると静止した 2 色リングになることを確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| ビームが回らず、1 周ごとにパッと切り替わる | `@property --bb-angle` が登録されていない(削除した / `@media` の中に入れた)と角度が補間されない | `@property` はファイルのトップレベルに置き、`syntax: "<angle>"` / `inherits: false` を保つ |
| グラデーションは出るが止まったまま | `conic-gradient(from var(--bb-angle) …)` をルート要素で宣言した。値がルートで確定し、子のアニメーションが反映されない | グラデーションはアニメーションする要素(`.beam` / `.glow`)自身で宣言する |
| 別コンポーネントの回転がおかしくなる | `@property` はページ全体で共有。`--angle` のような汎用名だと他と衝突する | 接頭辞付きの `--bb-angle` のまま使う |
| `className` の Tailwind で文字サイズや余白が変わらない | Tailwind v4 のユーティリティは `@layer` 内にあり、レイヤー外の CSS Modules に必ず負ける | `--bb-font-size` / `--bb-padding` / `--bb-surface` の CSS 変数で渡す(モジュールはこれらをルートで宣言していない) |
| グローが背景の後ろに隠れる / 切れる | ルートの `isolation: isolate` を外した、または `overflow: hidden` を付けた | ルートは `isolation: isolate` を保ち、`overflow: hidden` は付けない(クリップは内側のレイヤーだけ) |
| 古い Safari / Firefox で回らない | `@property` 非対応。コンポーネントが `CSS.registerProperty` の有無を見て `data-bb-fallback` を付け、疑似要素の `transform: rotate` に切り替える | `data-bb-fallback` 系のセレクタと `container-type: size` を消さない |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- React 19 + TypeScript、スタイルは CSS Modules のみ(コンポーネント内に Tailwind を使わない)。外部依存なし。`"use client"`
- Props: `as`(`"button"` | `"a"` | `"div"`、既定 button)、`children`、`colorFrom`(既定 `#8b5cf6`)、`colorTo`(既定 `#22d3ee`)、`duration`(秒、既定 4)、`borderWidth`(px、既定 1.5)、`radius`(px、既定 999 = ピル)、`variant`(`"beam"` | `"rainbow"` | `"pulse"`、既定 beam)、`glow`(既定 true)、`glowIntensity`(0–1、既定 0.6)、`shimmer`(既定 false)、`className`、`style`、加えて `onClick` などの HTML 属性、button 用 `type` / `disabled`、a 用 `href` / `target` / `rel`
- 既定値は CSS 側(`--bb-from` / `--bb-to` / `--bb-duration` / `--bb-width` / `--bb-radius` / `--bb-glow-opacity` / `--bb-glow-blur`)に持ち、既定と異なる値だけインラインの CSS 変数で上書きする
### 見た目
- レイヤー(奥 → 手前): **グロー**(ビームと同じ背景を `filter: blur(14px)`、`opacity: .6`、`z-index: -1` で外にはみ出す)→ **ビーム**(`inset: 0`、角丸でクリップ、背景色 `rgba(255,255,255,.1)` のトラック + コニックグラデーション)→ **内側の面**(ルートの `padding: borderWidth` で内側に置いた不透明の面。はみ出したビームの帯がそのままリングになる)→ シマー → 中身
- ビーム(beam): `conic-gradient(from var(--bb-angle), transparent 0 62%, colorFrom 84%, colorTo 97%, 先端 100%)`。先端は `color-mix(in srgb, colorTo 35%, white)`。先端で終わり 0% を透明で始めるので、進行方向の縁がシャープになる
- レインボー: `#ff4d6d, #ff9f43, #ffd93d, #4ade80, #22d3ee, #6366f1, #c084fc, #ff4d6d`(最後 = 最初で継ぎ目なし)。colorFrom / colorTo は使わない
- パルス: `colorFrom, colorTo, colorFrom` の全周リング
- 内側の面: `#0c0c10`(`--bb-surface` で上書き可)+ 上から白 .06 → 0 のグラデーション、`inset 0 1px 0 白 .08` のハイライト、SVG の `feTurbulence` ノイズを `opacity .07` / `overlay` で薄く重ねる。角丸は `radius − borderWidth`
- button / a: 14px / 500 / 字間 -0.01em、余白 `0.72em 1.5em`(`--bb-padding` / `--bb-font-size` で上書き可)。div は余白 0
- シマー: 105° の帯(透明 → colorTo 22% を混ぜた白 → 透明)が面を左から右へ横切る
### モーション
- `@property --bb-angle { syntax: "<angle>"; initial-value: 0deg; inherits: false }` を登録し、ビームとグローで `--bb-angle: 0 → 360deg` を `duration` 秒・linear・無限ループ
- パルス: グローに `opacity`(×0.35 ↔ ×1)と `scale(.97 ↔ 1.03)` の呼吸を `duration × 0.6` 秒周期で追加
- シマー: `translateX(-120% → 120%)` を周期の前半 55% で走らせ、残りは休止(常時流れるとローディング表示に見える)。周期は `duration × 0.9 + 1.2s`
- `@property` 非対応ブラウザ: `CSS.registerProperty` が無ければルートに `data-bb-fallback` を付け、`container-type: size` のレイヤー内で幅 `calc(100cqw + 100cqh)`・正方形の疑似要素に同じグラデーションを敷き、`transform: rotate` で回す(幅 + 高さ ≥ 対角線なので四隅が欠けない)
- 画面外では IntersectionObserver でルートに `data-paused` を付け、全アニメーションを `animation-play-state: paused`。disabled でも停止
- ホバー(`hover: hover` のみ): `translateY(-1px)` とグロー ×1.5。押下 `scale(.98)`。div はフォーカスが中に入るとグロー ×1.5
- `prefers-reduced-motion: reduce`: アニメーションを全て止め、beam / pulse は 135° で静止した colorFrom → colorTo の全周リング、シマーは非表示
### アクセシビリティ
- 実要素は `<button type="button">` / `<a>` / `<div>`。装飾レイヤー(グロー・ビーム・シマー)は `aria-hidden`、ラベルは通常のテキストのまま読み上げられる
- `:focus-visible` で colorTo の 2px アウトライン(offset 3px)
- `as="a"` で disabled のときは `href` を外し `aria-disabled="true"` / `tabIndex={-1}`。button は native の `disabled`
- マウント時にフォーカスを奪わない
### 受け入れ条件
- 暗い背景で、光の尾を引くビームがボーダーを一定速度で一周し続け、背後にぼんやりとした発光が付いてくる
- レインボーは継ぎ目なく回り、パルスはグローがゆっくり明滅する
- `as="div"` で入力欄やカードを包んでも中身のレイアウトを崩さない
- 視差効果を減らす設定では静止した 2 色リングになり、壊れて見えない
- 画面外に出るとアニメーションが止まるインストール
npx shadcn@latest add https://anima-js.vercel.app/r/border-beam.json生成コード
import { BorderBeam } from "@/components/border-beam";
// TODO: <BorderBeam …>新機能を試す ✦</BorderBeam> のようにラベルを children として渡し、onClick で処理をつないでください
// TODO: リンクにするなら as="a" href="…"、カードや入力欄の枠にするなら as="div" を指定してください
<BorderBeam
variant="beam"
colorFrom="#8b5cf6"
colorTo="#22d3ee"
borderWidth={1.5}
radius={999}
glow={true}
glowIntensity={0.6}
shimmer={false}
duration={4}
/>