レイアウト
InfiniteMarquee
CSS
継ぎ目なく流れ続けるマーキー — ロゴウォール、レビューの帯、傾いた色帯のキネティックテキスト。中身を測って px/s 一定で流し、端は mask でフェード。純粋な CSS アニメーション。
世界中のプロダクトチームが採用
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約27 KB)。
# InfiniteMarquee をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: InfiniteMarquee
- 説明: 継ぎ目なく流れ続けるマーキー — ロゴウォール、レビューの帯、傾いた色帯のキネティックテキスト。中身を測って px/s 一定で流し、端は mask でフェード。純粋な CSS アニメーション。
- 構成ファイル: `components/infinite-marquee/index.tsx`, `components/infinite-marquee/InfiniteMarquee.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/infinite-marquee/index.tsx`
```tsx
"use client";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type ReactNode,
} from "react";
import styles from "./InfiniteMarquee.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type InfiniteMarqueeDirection = "left" | "right";
export type InfiniteMarqueeVariant = "plain" | "band";
export type InfiniteMarqueeProps = {
/** The items of one pass (logos, cards, words). Rendered as flex items. */
children: ReactNode;
/** Scroll speed in px per second — constant regardless of item count. */
speed?: number;
/**
* "left" moves content toward the start (up when `vertical`), "right"
* toward the end (down when `vertical`).
*/
direction?: InfiniteMarqueeDirection;
/** Pause while the pointer is over the marquee (and while it holds focus). */
pauseOnHover?: boolean;
/** Fade both edges out with a mask. */
fade?: boolean;
/** Width (height when vertical) of each edge fade in px. */
fadeWidth?: number;
/** Space between items, and between passes, in px. */
gap?: number;
/** Scroll on the block axis. Give the marquee a height (e.g. via className). */
vertical?: boolean;
/**
* "plain" leaves styling to the children. "band" is the kinetic-type look:
* a tilted full-bleed colored band with big uppercase text.
*/
variant?: InfiniteMarqueeVariant;
/** Band fill (variant="band"). The text color follows its lightness. */
bandColor?: string;
/** Band tilt in degrees (variant="band"). */
bandRotate?: number;
className?: string;
/** Names the marquee for assistive tech (rendered as a labelled group). */
"aria-label"?: string;
};
const DEFAULT_SPEED = 60;
const DEFAULT_FADE_WIDTH = 96;
const DEFAULT_GAP = 48;
const DEFAULT_BAND_COLOR = "#d4ff3f";
const DEFAULT_BAND_ROTATE = -3;
/** Upper bound on rendered passes, so a 1px-wide child can't explode the DOM. */
const MAX_COPIES = 24;
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const media = window.matchMedia(REDUCED_QUERY);
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}
/** OS "reduce motion" setting; false during SSR and hydration. */
function useReducedMotion() {
return useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/**
* Dark or light ink for text on the band, from the fill's relative
* luminance. Only #rgb / #rrggbb are parsed; anything else keeps dark ink,
* which suits the bright fills the band is designed around.
*/
function inkFor(color: string): string {
const hex = color.trim().replace(/^#/, "");
const full =
hex.length === 3
? hex
.split("")
.map((c) => c + c)
.join("")
: hex;
if (!/^[0-9a-f]{6}$/i.test(full)) return "#0a0a0a";
const [r, g, b] = [0, 2, 4].map((i) => {
const c = parseInt(full.slice(i, i + 2), 16) / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance > 0.36 ? "#0a0a0a" : "#fafafa";
}
/**
* Seamless infinite marquee. The children are rendered as one pass, the
* pass is repeated until it covers the container, and that whole set is
* rendered twice — so a CSS translate of exactly -50% lands on a frame
* identical to the first and the loop never jumps. Duration is derived
* from the measured set length, so `speed` stays in px/s whatever the
* content. Every copy but the first is aria-hidden + inert: assistive tech
* and the Tab key see the content once.
*/
export function InfiniteMarquee({
children,
speed = DEFAULT_SPEED,
direction = "left",
pauseOnHover = true,
fade = true,
fadeWidth = DEFAULT_FADE_WIDTH,
gap = DEFAULT_GAP,
vertical = false,
variant = "plain",
bandColor = DEFAULT_BAND_COLOR,
bandRotate = DEFAULT_BAND_ROTATE,
className,
"aria-label": ariaLabel,
}: InfiniteMarqueeProps) {
const rootRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const groupRef = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
// `size` = one pass's length on the scroll axis (incl. its trailing gap),
// `copies` = passes per half. Updated only when the measurement changes,
// never per frame — the motion itself is a compositor-only CSS animation.
const [layout, setLayout] = useState({ size: 0, copies: 1 });
const [offscreen, setOffscreen] = useState(false);
useEffect(() => {
const viewport = viewportRef.current;
const group = groupRef.current;
if (!viewport || !group) return;
const measure = () => {
// offset*/client* are layout sizes, unaffected by the band's rotate.
const size = vertical ? group.offsetHeight : group.offsetWidth;
const box = vertical ? viewport.clientHeight : viewport.clientWidth;
if (!size) return;
// One half must be at least as long as the viewport, so the track
// (two halves) always spans 2× the container: no gap ever shows.
const copies = Math.min(MAX_COPIES, Math.max(1, Math.ceil(box / size)));
setLayout((prev) =>
prev.size === size && prev.copies === copies ? prev : { size, copies },
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(viewport);
observer.observe(group);
return () => observer.disconnect();
}, [vertical]);
// Pause while scrolled out of view — the animation is cheap, but not free.
useEffect(() => {
const root = rootRef.current;
if (!root || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(([entry]) =>
setOffscreen(!entry.isIntersecting),
);
observer.observe(root);
return () => observer.disconnect();
}, []);
const ready = layout.size > 0 && speed > 0;
const copies = reduced ? 1 : layout.copies;
// Reduced motion renders a single pass (no duplicates); otherwise two
// halves of `copies` passes each.
const passCount = reduced ? 1 : copies * 2;
const vars: CSSVars = {};
if (ready) {
vars["--im-duration"] = `${((layout.size * copies) / speed).toFixed(3)}s`;
}
if (gap !== DEFAULT_GAP) vars["--im-gap"] = `${gap}px`;
if (fadeWidth !== DEFAULT_FADE_WIDTH) vars["--im-fade"] = `${fadeWidth}px`;
if (variant === "band") {
if (bandColor.toLowerCase() !== DEFAULT_BAND_COLOR) {
vars["--im-band-color"] = bandColor;
vars["--im-band-ink"] = inkFor(bandColor);
}
if (bandRotate !== DEFAULT_BAND_ROTATE) {
vars["--im-band-rotate"] = `${bandRotate}deg`;
}
}
return (
<div
ref={rootRef}
className={className ? `${styles.root} ${className}` : styles.root}
role={ariaLabel ? "group" : undefined}
aria-label={ariaLabel}
data-variant={variant}
data-axis={vertical ? "y" : "x"}
data-direction={direction}
data-fade={fade || undefined}
data-pause-on-hover={pauseOnHover || undefined}
data-ready={ready || undefined}
data-offscreen={offscreen || undefined}
style={vars}
>
<div
ref={viewportRef}
className={styles.viewport}
// Reduced motion turns the row into a scroller; make it reachable
// by keyboard so overflowing items can still be read.
tabIndex={reduced ? 0 : undefined}
>
<div className={styles.track}>
{Array.from({ length: passCount }, (_, i) => (
<div
key={i}
ref={i === 0 ? groupRef : undefined}
className={styles.group}
aria-hidden={i > 0 || undefined}
inert={i > 0 || undefined}
>
{children}
</div>
))}
</div>
</div>
</div>
);
}
export default InfiniteMarquee;
```
#### `components/infinite-marquee/InfiniteMarquee.module.css`
```css
/* ==========================================================================
Infinite marquee
root (band fill + tilt) → viewport (clips, edge-fade mask) → track (the
animated strip) → groups (identical passes of the children). The track
holds two equal halves, so translating it by exactly -50% loops without
a seam. Only transform animates; the duration comes from the measured
pass length (see the component), keeping speed in px/s.
========================================================================== */
.root {
/* Defaults. The component only sets these inline for non-default
values, so the default render is exactly this stylesheet. */
--im-duration: 30s; /* replaced by the measured value once ready */
--im-gap: 48px;
--im-fade: 96px;
--im-band-color: #d4ff3f;
--im-band-ink: #0a0a0a;
--im-band-rotate: -3deg;
position: relative;
min-width: 0;
max-width: 100%;
}
.viewport {
display: flex;
/* clip, not hidden: a hidden box is still programmatically scrollable,
so focusing a link that is translated out of view would shift
scrollLeft and misalign the strip for good. */
overflow: clip;
outline: none;
}
.root[data-axis="y"] {
/* A vertical marquee needs a height to scroll inside; this is only the
fallback when the consumer sets none. */
block-size: 360px;
}
.root[data-axis="y"] .viewport {
block-size: 100%;
}
/* --- Edge fade. A mask (not an overlay gradient) so it works on any
backdrop. Unprefixed only: the build adds -webkit- where needed, and
writing both lets Lightning CSS drop the unprefixed declaration. --- */
.root[data-fade] .viewport {
mask-image: linear-gradient(
to right,
transparent,
#000 var(--im-fade),
#000 calc(100% - var(--im-fade)),
transparent
);
}
.root[data-fade][data-axis="y"] .viewport {
mask-image: linear-gradient(
to bottom,
transparent,
#000 var(--im-fade),
#000 calc(100% - var(--im-fade)),
transparent
);
}
.track {
display: flex;
flex: none;
width: max-content;
}
.root[data-axis="y"] .track {
flex-direction: column;
width: auto;
height: max-content;
}
/* One pass of the children. The gap is also applied after the last item
(padding-inline-end), so each pass is exactly `item…gap` long and the
seam between passes matches the spacing inside them. */
.group {
display: flex;
flex: none;
align-items: center;
gap: var(--im-gap);
padding-inline-end: var(--im-gap);
}
.group > * {
flex: none;
}
.root[data-axis="y"] .group {
flex-direction: column;
padding-inline-end: 0;
padding-block-end: var(--im-gap);
}
/* --- Motion. Starts only once measured, so the first frames never run
at a placeholder speed. --- */
.root[data-ready] .track {
animation: im-scroll-x var(--im-duration) linear infinite;
will-change: transform;
}
.root[data-ready][data-axis="y"] .track {
animation-name: im-scroll-y;
}
.root[data-ready][data-direction="right"] .track {
animation-direction: reverse;
}
/* Pausing freezes in place (play-state), so resuming has no jump. Focus
inside also pauses: a keyboard user must not chase a moving link. */
.root[data-pause-on-hover] .viewport:hover .track,
.root[data-pause-on-hover] .viewport:focus-within .track,
.root[data-offscreen] .track {
animation-play-state: paused;
}
@keyframes im-scroll-x {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(-50%, 0, 0);
}
}
@keyframes im-scroll-y {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(0, -50%, 0);
}
}
/* ==========================================================================
Band variant — kinetic type on a tilted, full-bleed colored strip
========================================================================== */
.root[data-variant="band"] {
/* Font size follows the band's own width (cqi), so the text keeps its
proportion in a narrow column, a full-width hero or a scaled preview. */
container-type: inline-size;
/* Wider than the container so the tilted ends never show a corner.
The parent is expected to clip (overflow: hidden / clip). */
inline-size: 110%;
max-width: none;
margin-inline: -5%;
padding-block: 0.9em;
rotate: var(--im-band-rotate);
background: var(--im-band-color);
color: var(--im-band-ink);
font-family:
"Inter Tight", "Inter", ui-sans-serif, system-ui, -apple-system,
"Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
font-size: clamp(14px, 1.6cqi, 22px);
/* Glow in the band's own color + a soft contact shadow: it reads as a
lit strip of tape over the page, not a flat rectangle. */
box-shadow:
0 24px 60px -18px color-mix(in srgb, var(--im-band-color) 55%, transparent),
0 14px 30px -12px rgba(0, 0, 0, 0.55);
isolation: isolate;
}
/* Sheen: a top highlight and a slight darkening toward the bottom edge. */
.root[data-variant="band"]::before,
.root[data-variant="band"]::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
}
.root[data-variant="band"]::before {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.28) 0%,
rgba(255, 255, 255, 0) 38%,
rgba(0, 0, 0, 0) 62%,
rgba(0, 0, 0, 0.14) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.45),
inset 0 -1px 0 rgba(0, 0, 0, 0.18);
}
/* Fine grain, so the flat fill has a printed, tactile surface. */
.root[data-variant="band"]::after {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.16;
mix-blend-mode: overlay;
}
.root[data-variant="band"] .viewport {
/* Above the sheen/grain layers, so the type stays crisp. */
position: relative;
z-index: 2;
}
.root[data-variant="band"] .group {
white-space: nowrap;
font-size: clamp(30px, 6.4cqi, 96px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.035em;
text-transform: uppercase;
}
/* ==========================================================================
Reduced motion — a single, still pass. Overflow becomes a quiet
horizontal (or vertical) scroller instead of being clipped.
========================================================================== */
@media (prefers-reduced-motion: reduce) {
.root .track {
animation: none !important;
will-change: auto;
margin-inline: auto;
}
.root[data-axis="y"] .track {
margin-inline: 0;
margin-block: auto;
}
/* Before hydration the duplicates may still be in the DOM. */
.group[aria-hidden="true"] {
display: none;
}
.group {
padding-inline: var(--im-gap);
}
.root[data-axis="y"] .group {
padding-inline: 0;
padding-block: var(--im-gap);
}
.root .viewport,
.root[data-fade] .viewport,
.root[data-fade][data-axis="y"] .viewport {
overflow: auto;
mask-image: none;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.root .viewport:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/infinite-marquee.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { InfiniteMarquee } from "@/components/infinite-marquee";
// TODO: children を表示したい要素(ロゴ・テキスト・カード)に差し替えてください。<InfiniteMarquee …>…</InfiniteMarquee> の形で書いても同じです。各要素は横並びの 1 アイテムとして扱われます
// TODO: 親要素に幅(縦の場合は vertical と高さ)を与えてください。band の場合は傾いた帯がはみ出すので、親に overflow: hidden(または clip)を指定します
<InfiniteMarquee
speed={60}
direction="left"
pauseOnHover={true}
fade={true}
fadeWidth={96}
gap={48}
variant="band"
bandColor="#d4ff3f"
bandRotate={-3}
aria-label="スローガン"
children={[
<span key="Design">Design</span>,
<span key="Design-sep" aria-hidden="true">✦</span>,
<span key="Animate">Animate</span>,
<span key="Animate-sep" aria-hidden="true">✦</span>,
<span key="Ship">Ship</span>,
<span key="Ship-sep" aria-hidden="true">✦</span>,
]}
/>
```
### 4. 組み込み手順
1. 流したい要素を `children` として並べる: `<InfiniteMarquee aria-label="導入企業">{logos}</InfiniteMarquee>`。`children` の直下の要素がそれぞれ 1 アイテムになる(ラッパーで 1 つにまとめない)。
2. 親要素に幅を与える(ブロック要素なら通常は自動で 100%)。中身が少なくても、コンポーネントがコンテナ幅を覆うまで自動で複製する。
3. 速度は `speed`(px/秒)で指定する。アイテムを増減しても体感速度は変わらない。
4. `variant="band"` を使う場合、帯は親より 10% 広く・傾けて描かれるので、**親に `overflow: hidden`(または `overflow: clip`)を指定**する。文字サイズは帯の幅に追従する(`cqi`)。
5. 縦に流す場合は `vertical` を付け、`className` などで高さを与える(未指定時は 360px)。
6. リンクやボタンを流す場合、最初の 1 周分だけがフォーカス可能で、複製は `aria-hidden` + `inert`。フォーカス中とホバー中(`pauseOnHover`)は止まる。
7. `npm run build` が通ることを確認し、「ループの継ぎ目でガクッとしない」「ウィンドウを広げても隙間が出ない」「OS の視差効果を減らす設定で止まる」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| 1 周ごとにガクッと戻る | トラックの 2 つの半分の長さが違う / 最後のアイテムの後ろに gap が無い | 各パスは `padding-inline-end: gap` で「アイテム…gap」の長さに揃えてある。`gap` をパス同士の `gap` に置き換えない(-50% がずれる) |
| Chrome で端のフェードが効かない | `mask-image` と `-webkit-mask-image` を併記すると Next.js(Lightning CSS)が unprefixed を削ることがある | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| 広い画面で右端に空白が出る | 複製数が固定 | ResizeObserver でパス長とコンテナ幅を測り、`ceil(コンテナ / パス長)` 個を 1 セットにして 2 セット描画する仕組みを外さない |
| アイテム数で速度が変わる | `animation-duration` を固定値にしている | 周期 = セット長 ÷ `speed`。測定値から算出している部分を残す |
| スクリーンリーダーが同じ内容を何度も読む | 複製に `aria-hidden` / `inert` が無い | 2 つ目以降のパスの `aria-hidden` と `inert` を外さない |
| 帯の端に三角の隙間が見える | 帯の幅を 100% に戻した | 帯は `inline-size: 110%` + `margin-inline: -5%`。傾きを大きくする場合は幅も広げる |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules。ランタイム依存なし(アニメーションは CSS)
- ファイル: `components/infinite-marquee/InfiniteMarquee.tsx`(`"use client"`)+ `InfiniteMarquee.module.css`
- props: `children`、`speed`(px/秒、既定 60)、`direction`(`"left" | "right"`、既定 left)、`pauseOnHover`(既定 true)、`fade`(既定 true)、`fadeWidth`(px、既定 96)、`gap`(px、既定 48)、`vertical`、`variant`(`"plain" | "band"`、既定 plain)、`bandColor`(既定 `#d4ff3f`)、`bandRotate`(deg、既定 -3)、`className`、`aria-label`
- 既定値は CSS 変数(`--im-gap` / `--im-fade` / `--im-band-color` / `--im-band-ink` / `--im-band-rotate` / `--im-duration`)としてルートに持ち、既定と異なる値だけ inline style で上書きする
### 構造
- ルート(帯の色と傾き)→ ビューポート(`overflow: hidden`、フェードのマスク)→ トラック(`display: flex; width: max-content`、アニメーション対象)→ パス(`children` を 1 周分、`display: flex; gap; padding-inline-end: gap; white-space: nowrap`)
- パス長 `size` とビューポート幅 `box` を ResizeObserver で測り(`offsetWidth` / `clientWidth` = 回転の影響を受けないレイアウト値)、`copies = ceil(box / size)`(上限 24)。トラックには `copies × 2` 個のパスを描画 → 半分が必ずコンテナ以上の長さになり、全体は 2 倍以上を覆う
- 1 個目のパス以外はすべて `aria-hidden` + `inert`
- `aria-label` があればルートを `role="group"` にする(ランドマークは増やさない)
### 見た目
- **plain**: 子要素のスタイルに任せる。アイテムは `flex: none` で縦中央揃え
- **フェード**: ビューポートに `mask-image: linear-gradient(to right, transparent, #000 fadeWidth, #000 calc(100% - fadeWidth), transparent)`(縦は to bottom)。オーバーレイではなくマスクなので背景色を問わない
- **band**: ルートに `background: bandColor`、`rotate: bandRotate`、`inline-size: 110%; margin-inline: -5%`(傾けても端が見えない)、`padding-block: .9em`、`container-type: inline-size`
- 文字: `font-size: clamp(30px, 6.4cqi, 96px)`、800、`line-height: 1`、`letter-spacing: -0.035em`、大文字。文字色は帯の相対輝度 > 0.36 なら `#0a0a0a`、それ以外は `#fafafa`
- 光沢: `::before` に上から白 .28 → 0(38%)、下端に黒 .14 のグラデーション + `inset 0 1px 0 白 .45`
- 質感: `::after` に SVG の fractalNoise(opacity .16、`mix-blend-mode: overlay`)
- 影: `0 24px 60px -18px` 帯の色 55% の発光 + `0 14px 30px -12px rgba(0,0,0,.55)`
- 区切り記号(✦ など)は子要素として渡し、`aria-hidden` にする
### モーション
- `@keyframes` で `translate3d(0,0,0) → translate3d(-50%,0,0)`(縦は Y)、`linear infinite`。`direction="right"` は `animation-direction: reverse`
- 周期 = `(size × copies) / speed` 秒。測定が終わるまでアニメーションを付けない(仮の速度で走らせない)
- `pauseOnHover`: ビューポートの `:hover` と `:focus-within` で `animation-play-state: paused`(その場で止まり、再開時に跳ばない)
- IntersectionObserver で画面外にある間は一時停止
- `prefers-reduced-motion: reduce`: アニメーションなし、複製を描画せず 1 周分だけを中央寄せ。はみ出す分はビューポートを `overflow: auto` のスクロール領域にし、`tabIndex={0}` とフォーカスリングでキーボードからも読めるようにする(マスクは外す)
### アクセシビリティ
- 内容は 1 回だけ読み上げられる(複製は `aria-hidden` + `inert`、Tab でも到達しない)
- 動く内容はホバー / フォーカスで止まり、視差効果を減らす設定では完全に静止する
### 受け入れ条件
- ループの継ぎ目が見えない(ガクッとしない)
- アイテムが 2〜3 個でも、ワイドな画面で隙間なく埋まる
- アイテムを増やしても速度(px/秒)が変わらない
- `direction` を変えると逆に流れる。ホバーで止まる
- band で傾いた色帯の端が親の中で途切れて見えないインストール
npx shadcn@latest add https://anima-js.vercel.app/r/infinite-marquee.json生成コード
import { InfiniteMarquee } from "@/components/infinite-marquee";
// TODO: children を表示したい要素(ロゴ・テキスト・カード)に差し替えてください。<InfiniteMarquee …>…</InfiniteMarquee> の形で書いても同じです。各要素は横並びの 1 アイテムとして扱われます
// TODO: 親要素に幅(縦の場合は vertical と高さ)を与えてください。band の場合は傾いた帯がはみ出すので、親に overflow: hidden(または clip)を指定します
<InfiniteMarquee
speed={60}
direction="left"
pauseOnHover={true}
fade={true}
fadeWidth={96}
gap={48}
variant="band"
bandColor="#d4ff3f"
bandRotate={-3}
aria-label="スローガン"
children={[
<span key="Design">Design</span>,
<span key="Design-sep" aria-hidden="true">✦</span>,
<span key="Animate">Animate</span>,
<span key="Animate-sep" aria-hidden="true">✦</span>,
<span key="Ship">Ship</span>,
<span key="Ship-sep" aria-hidden="true">✦</span>,
]}
/>