9 CSS Marquee Infinite Loop — No JS, Scroll-Driven
A css marquee infinite loop is a row of duplicated content — logos, a price ticker, a headline — that slides on repeat using only a CSS transform and a
01 marquee-infinite-scroll-seamless-loop
02 marquee-infinite-scroll-logo-marquee
03 marquee-infinite-scroll-pause-hover
04 marquee-infinite-scroll-fade-edges-mask
05 marquee-infinite-scroll-vertical-ticker
06 marquee-infinite-scroll-dual-direction
07 marquee-infinite-scroll-variable-speed
08 marquee-infinite-scroll-3d-curve
09 marquee-infinite-scroll-reduced-motion-static
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Seamless loop formula
- 02 Infinite logo marquee
- 03 Pause on hover
- 04 Fade edges via mask
- 05 Vertical ticker
- 06 Dual direction rows
- 07 Variable-speed flow
- 08 3D curved marquee
- 09 Static fallback for reduced motion
The nine below build in the order the technique actually grows. 01 is the bare formula every other item reuses. 02 turns it into the layout most sites actually ship. 03 and 04 add an interaction and a visual finish. 05 and 06 bend the same formula onto a new axis and a second track. 07 and 08 are the idea-form entries — a non-linear rhythm and a curved, three-dimensional wall — and 09 closes on the one pattern built entirely around what a browser setting removes rather than what CSS adds. Every loop below runs on a two-second cycle so the grid keeps moving inside a fixed, non-scrollable iframe. A real banner's css marquee speed is usually ten to twenty seconds per lap, which reads calmer than what's playing above.
01Seamless loop formula
How it works
Six chips, lettered A through F, sit in a flex track duplicated into exactly two copies, and the track only ever moves from translateX(0) to translateX(-50%). Each chip carries its own margin-right for the space between them, so the first six chips add up to exactly half the track's total width and the clone lands on the original's start position without a seam.
Where it fits
Every pattern below borrows this same skeleton.

<main class="stage">
<div class="marquee">
<div class="marquee__track">
<span class="marquee__chip">A</span>
<span class="marquee__chip">B</span>
<span class="marquee__chip">C</span>
<span class="marquee__chip">D</span>
<span class="marquee__chip">E</span>
<span class="marquee__chip">F</span>
<span class="marquee__chip" aria-hidden="true">A</span>
<span class="marquee__chip" aria-hidden="true">B</span>
<span class="marquee__chip" aria-hidden="true">C</span>
<span class="marquee__chip" aria-hidden="true">D</span>
<span class="marquee__chip" aria-hidden="true">E</span>
<span class="marquee__chip" aria-hidden="true">F</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 실제 서비스는 12~20s 가 보통. 데모는 gif 루프에 맞춰 2s 로 배속
$easing: linear; // 등속 — 완급을 준 버전은 07
$color: $subject-cream;
@include stage($stage-ink);
.marquee {
width: min(320px, 84vw);
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
animation: marquee-scroll $duration $easing infinite;
}
.marquee__chip {
margin-right: 14px; // 트랙의 gap 대신 — 세트 폭이 정확히 절반이 되어 -50% 이음새가 0px 이 된다
display: grid;
place-items: center;
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: $r-control;
background: $color;
color: $stage-ink;
font-weight: 700; font-size: 16px; line-height: 1; font-family: inherit;
}
// 6개를 그대로 한 번 더 복제해 트랙 하나에 12개를 나란히 두었다.
// 간격은 트랙의 gap 이 아니라 칩의 margin-right 로 준다. gap 은 12개 사이에 11번만 생겨
// -50% 가 gap 의 절반만큼 모자라고, 한 바퀴마다 그만큼 튄다 (이 편 9개 전부에서 4~20px 실측).
// margin-right 면 세트 하나의 폭 = (칩 + 간격) × 6 이라 -50% 가 픽셀 단위로 겹친다.
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.marquee__track { animation: none; transform: translateX(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.marquee {
width: min(320px, 84vw);
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
animation: marquee-scroll 2s linear infinite;
}
.marquee__chip {
margin-right: 14px;
display: grid;
place-items: center;
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 16px;
line-height: 1;
font-family: inherit;
}
@keyframes marquee-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee__track {
animation: none;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./SeamlessLoopMarquee.module.scss";
export type SeamlessLoopMarqueeProps = {
items?: string[];
/** 한 바퀴가 도는 시간 (기본 12000ms — 데모 gif 는 2000ms 로 배속) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SeamlessLoopMarquee({
items = ["A", "B", "C", "D", "E", "F"],
duration = 12000,
easing = "linear",
color = "#fff7e6",
}: SeamlessLoopMarqueeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.marquee} style={vars}>
<div className={styles.track}>
{items.map((it) => (
<span key={it} className={styles.chip}>{it}</span>
))}
{items.map((it) => (
<span key={"dup-" + it} className={styles.chip} aria-hidden="true">{it}</span>
))}
</div>
</div>
);
}01 · Seamless loop formula — Measured area 8.889% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.marquee__track {
display: flex;
width: max-content;
animation: marquee-scroll 12s linear infinite;
}
.marquee__chip {
margin-right: 14px;
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
02Infinite logo marquee
Turning it into a css logo marquee
Five wordmarks — NORTH, ACME, ORBIT, VELA, and KADE — sit inside pill-shaped cards under a small "Partners" label and stream through the same seamless track as item 01.
Where it fits
Nothing about the pattern changes; only the chip markup adds a shadow and a rounded background, so the row reads as a client strip instead of a raw alphabet test.

<main class="stage">
<div class="marquee">
<p class="marquee__label">Partners</p>
<div class="marquee__row">
<div class="marquee__track">
<span class="marquee__logo">NORTH</span>
<span class="marquee__logo">ACME</span>
<span class="marquee__logo">ORBIT</span>
<span class="marquee__logo">VELA</span>
<span class="marquee__logo">KADE</span>
<span class="marquee__logo" aria-hidden="true">NORTH</span>
<span class="marquee__logo" aria-hidden="true">ACME</span>
<span class="marquee__logo" aria-hidden="true">ORBIT</span>
<span class="marquee__logo" aria-hidden="true">VELA</span>
<span class="marquee__logo" aria-hidden="true">KADE</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: linear;
$color: $stage-ink;
@include stage($stage-yellow);
.marquee { width: min(340px, 88vw); display: grid; gap: 10px; justify-items: center; }
.marquee__label {
margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); line-height: 1; font-family: inherit;
letter-spacing: .08em;
color: rgba($color, .68);
text-transform: uppercase;
}
.marquee__row { width: 100%; overflow: hidden; }
.marquee__track {
display: flex;
width: max-content;
animation: logo-scroll $duration $easing infinite;
}
.marquee__logo {
margin-right: 16px; // 트랙의 gap 대신 — 세트 폭이 정확히 절반이 되어 -50% 이음새가 0px 이 된다
flex-shrink: 0;
display: grid;
place-items: center;
height: 34px;
padding: 0 16px;
border-radius: $r-pill;
background: $stage-paper;
color: $color;
font-weight: 800; font-size: 13px; line-height: 1; font-family: inherit;
letter-spacing: .04em;
box-shadow: $shadow-press;
}
@keyframes logo-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.marquee__track { animation: none; transform: translateX(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.marquee {
width: min(340px, 88vw);
display: grid;
gap: 10px;
justify-items: center;
}
.marquee__label {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.08em;
color: rgba(23, 20, 26, 0.68);
text-transform: uppercase;
}
.marquee__row {
width: 100%;
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
animation: logo-scroll 2s linear infinite;
}
.marquee__logo {
margin-right: 16px;
flex-shrink: 0;
display: grid;
place-items: center;
height: 34px;
padding: 0 16px;
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-weight: 800;
font-size: 13px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@keyframes logo-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee__track {
animation: none;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./LogoMarquee.module.scss";
export type LogoMarqueeProps = {
label?: string;
logos?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LogoMarquee({
label = "Partners",
logos = ["NORTH", "ACME", "ORBIT", "VELA", "KADE"],
duration = 12000,
easing = "linear",
color = "#17141a",
}: LogoMarqueeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.marquee} style={vars}>
<p className={styles.label}>{label}</p>
<div className={styles.row}>
<div className={styles.track}>
{logos.map((l) => (
<span key={l} className={styles.logo}>{l}</span>
))}
{logos.map((l) => (
<span key={"dup-" + l} className={styles.logo} aria-hidden="true">{l}</span>
))}
</div>
</div>
</div>
);
}02 · Infinite logo marquee — Measured area 8.028% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.marquee__track {
display: flex;
animation: logo-scroll 12s linear infinite;
}
.marquee__logo {
margin-right: 16px;
height: 34px;
padding: 0 16px;
border-radius: 999px;
background: #fff;
box-shadow: $shadow-press;
}
03Pause on hover
How it works
A single line — "Join today, first month free" — repeats once, and animation-play-state flips to paused the instant a pointer lands on :hover or a keyboard focuses the banner via :focus-within. That holds whatever word the reader was on.
Where it fits
Nothing about the track's position resets when it pauses, so hovering over the banner again a second later resumes from that same frame instead of restarting.

<main class="stage">
<div class="marquee is-demo" tabindex="0">
<div class="marquee__track">
<span class="marquee__chip">Join today, first month free</span>
<span class="marquee__chip" aria-hidden="true">Join today, first month free</span>
</div>
<span class="marquee__cursor" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 10s; // 실서비스 속도 — 데모는 아래 .is-demo 가 별도로 2s 루프에 맞춘다
$easing: linear;
$color: $subject-cream;
@include stage($stage-orange);
.marquee {
position: relative;
width: min(300px, 82vw);
overflow: hidden;
border-radius: $r-card;
padding: 16px 0;
background: rgba($color, .12);
outline: none;
}
.marquee__track {
display: flex;
width: max-content;
animation: marquee-scroll $duration $easing infinite;
}
.marquee__chip {
margin-right: 40px; // 트랙의 gap 대신 — 세트 폭이 정확히 절반이 되어 -50% 이음새가 0px 이 된다
flex-shrink: 0;
white-space: nowrap;
font-weight: 700; font-size: clamp(13px, 3.6vw, 15px); line-height: 1; font-family: inherit;
color: $subject-ink; // 주황 면 위 글자 — AA 4.5 (blog-design 글자 대비)
}
.marquee__cursor {
position: absolute;
top: 8px;
right: 22px;
width: 16px;
height: 16px;
border-radius: 50%;
background: $subject-blue;
box-shadow: 0 0 0 3px rgba($subject-blue, .28);
opacity: 0;
}
// 실제 사용자는 마우스를 올리면 여기서 멈춘다 — 읽던 문구가 그대로 남는다
.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track,
.marquee:active .marquee__track {
animation-play-state: paused;
}
// 데모 · 문서용: 진짜 hover 는 렌더에서 못 일으키니, 흐르다 멈췄다 다시 흐르는 한 사이클을
// 트랙 자신의 keyframes 안에 구간(plateau)으로 넣어 2초마다 반복시킨다. 커서 점은 그 구간에서만 보인다
.marquee.is-demo .marquee__track {
animation: marquee-hover-cycle $dur-loop $easing infinite;
}
.marquee.is-demo .marquee__cursor {
animation: cursor-hover $dur-loop $easing infinite;
}
@keyframes marquee-hover-cycle {
0% { transform: translateX(0); }
38% { transform: translateX(-32%); }
62% { transform: translateX(-32%); } // 정지 구간 — 실제 hover 로 멈춘 순간을 흉내
100% { transform: translateX(-50%); }
}
@keyframes cursor-hover {
0%, 30% { opacity: 0; transform: translateY(6px) scale(.6); }
38%, 62% { opacity: 1; transform: translateY(0) scale(1); }
70%, 100% { opacity: 0; transform: translateY(6px) scale(.6); }
}
@media (prefers-reduced-motion: reduce) {
.marquee__track { animation: none; transform: translateX(0); }
.marquee__cursor { animation: none; opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.marquee {
position: relative;
width: min(300px, 82vw);
overflow: hidden;
border-radius: 16px;
padding: 16px 0;
background: rgba(255, 247, 230, 0.12);
outline: none;
}
.marquee__track {
display: flex;
width: max-content;
animation: marquee-scroll 10s linear infinite;
}
.marquee__chip {
margin-right: 40px;
flex-shrink: 0;
white-space: nowrap;
font-weight: 700;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.marquee__cursor {
position: absolute;
top: 8px;
right: 22px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #2f6df6;
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.28);
opacity: 0;
}
.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track,
.marquee:active .marquee__track {
animation-play-state: paused;
}
.marquee.is-demo .marquee__track {
animation: marquee-hover-cycle 2s linear infinite;
}
.marquee.is-demo .marquee__cursor {
animation: cursor-hover 2s linear infinite;
}
@keyframes marquee-hover-cycle {
0% {
transform: translateX(0);
}
38% {
transform: translateX(-32%);
}
62% {
transform: translateX(-32%);
}
100% {
transform: translateX(-50%);
}
}
@keyframes cursor-hover {
0%, 30% {
opacity: 0;
transform: translateY(6px) scale(0.6);
}
38%, 62% {
opacity: 1;
transform: translateY(0) scale(1);
}
70%, 100% {
opacity: 0;
transform: translateY(6px) scale(0.6);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee__track {
animation: none;
transform: translateX(0);
}
.marquee__cursor {
animation: none;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./PauseHoverMarquee.module.scss";
export type PauseHoverMarqueeProps = {
text?: string;
/** 한 바퀴가 도는 시간 (기본 10000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PauseHoverMarquee({
text = "지금 가입하면 첫 달 무료",
duration = 10000,
easing = "linear",
color = "#fff7e6",
}: PauseHoverMarqueeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.marquee} style={vars} tabIndex={0}>
<div className={styles.track}>
<span className={styles.chip}>{text}</span>
<span className={styles.chip} aria-hidden="true">{text}</span>
</div>
</div>
);
}03 · Pause on hover — Measured area 3.244% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track,
.marquee:active .marquee__track {
animation-play-state: paused;
}
.marquee__track {
display: flex;
animation: marquee-scroll 10s linear infinite;
}
.marquee__chip {
margin-right: 40px;
}
04Fade edges via mask
How it works
Chips numbered 01 through 05 stream through a track wrapped in a container carrying mask-image. That fades the two edges of the strip into whatever sits behind them instead of clipping them with a hard overflow: hidden line.
Where it fits
Unprefixed mask-image reached Chrome 120, Safari 15.4, and Firefox 53 — caniuse tracks the exact versions — so the -webkit- duplicate in the demo is mostly a habit at this point, not a real requirement for a modern target list.

<main class="stage">
<div class="marquee">
<div class="marquee__track">
<span class="marquee__chip">01</span>
<span class="marquee__chip">02</span>
<span class="marquee__chip">03</span>
<span class="marquee__chip">04</span>
<span class="marquee__chip">05</span>
<span class="marquee__chip" aria-hidden="true">01</span>
<span class="marquee__chip" aria-hidden="true">02</span>
<span class="marquee__chip" aria-hidden="true">03</span>
<span class="marquee__chip" aria-hidden="true">04</span>
<span class="marquee__chip" aria-hidden="true">05</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-paper);
.marquee {
width: min(320px, 84vw);
overflow: hidden;
mask-image: linear-gradient(to right, transparent, #000 14%, #000 86%, transparent);
-webkit-mask-image: linear-gradient(to right, transparent, #000 14%, #000 86%, transparent);
}
.marquee__track {
display: flex;
width: max-content;
animation: marquee-scroll $duration $easing infinite;
}
.marquee__chip {
margin-right: 18px; // 트랙의 gap 대신 — 세트 폭이 정확히 절반이 되어 -50% 이음새가 0px 이 된다
flex-shrink: 0;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: $r-control;
border: 2px solid $color;
color: $color;
font-weight: 700; font-size: 14px; line-height: 1; font-family: inherit;
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.marquee__track { animation: none; transform: translateX(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.marquee {
width: min(320px, 84vw);
overflow: hidden;
mask-image: linear-gradient(to right, transparent, #000 14%, #000 86%, transparent);
-webkit-mask-image: linear-gradient(to right, transparent, #000 14%, #000 86%, transparent);
}
.marquee__track {
display: flex;
width: max-content;
animation: marquee-scroll 2s linear infinite;
}
.marquee__chip {
margin-right: 18px;
flex-shrink: 0;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 12px;
border: 2px solid #17141a;
color: #17141a;
font-weight: 700;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
@keyframes marquee-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee__track {
animation: none;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./FadeEdgeMarquee.module.scss";
export type FadeEdgeMarqueeProps = {
items?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FadeEdgeMarquee({
items = ["01", "02", "03", "04", "05"],
duration = 12000,
easing = "linear",
color = "#17141a",
}: FadeEdgeMarqueeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.marquee} style={vars}>
<div className={styles.track}>
{items.map((it) => (
<span key={it} className={styles.chip}>{it}</span>
))}
{items.map((it) => (
<span key={"dup-" + it} className={styles.chip} aria-hidden="true">{it}</span>
))}
</div>
</div>
);
}04 · Fade edges via mask — Measured area 10.501% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.marquee {
overflow: hidden;
mask-image: linear-gradient(to right,
transparent, #000 14%, #000 86%, transparent);
-webkit-mask-image: linear-gradient(to right,
transparent, #000 14%, #000 86%, transparent);
}
05Vertical ticker
How it works
Four rows — AAPL, GOOG, MSFT, NVDA, each with a price and an up or down arrow — sit in a fixed-height box and scroll upward instead of sideways. It uses translateY in place of translateX on the exact same two-copy, -50% formula from item 01.
Where it fits
Swapping the axis is the entire change; the duplication math underneath never moves.

<main class="stage">
<div class="ticker">
<div class="ticker__track">
<span class="ticker__row">AAPL <b>189.42</b> ▲1.2%</span>
<span class="ticker__row">GOOG <b>172.05</b> ▼0.4%</span>
<span class="ticker__row">MSFT <b>428.90</b> ▲0.8%</span>
<span class="ticker__row">NVDA <b>121.77</b> ▲2.1%</span>
<span class="ticker__row" aria-hidden="true">AAPL <b>189.42</b> ▲1.2%</span>
<span class="ticker__row" aria-hidden="true">GOOG <b>172.05</b> ▼0.4%</span>
<span class="ticker__row" aria-hidden="true">MSFT <b>428.90</b> ▲0.8%</span>
<span class="ticker__row" aria-hidden="true">NVDA <b>121.77</b> ▲2.1%</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue;
@include stage($stage-ink);
.ticker {
width: min(220px, 76vw);
height: 108px;
overflow: hidden;
border-radius: $r-control;
background: rgba($subject-cream, .06);
padding: 0 14px;
}
.ticker__track {
display: flex;
flex-direction: column;
animation: ticker-scroll $duration $easing infinite;
}
.ticker__row {
flex-shrink: 0;
height: 27px;
display: flex;
align-items: center;
gap: 6px;
font-weight: 600; font-size: clamp(11px, 3.2vw, 13px); line-height: 1; font-family: inherit;
color: rgba($subject-cream, .78);
b { color: $subject-blue-light; font-weight: 800; } // 어두운 면 위 파란 글자 — AA 4.5
}
// 세로 축에도 같은 2벌 복제 공식을 그대로 적용 — translateX 대신 translateY 로 -50%
@keyframes ticker-scroll {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.ticker__track { animation: none; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ticker {
width: min(220px, 76vw);
height: 108px;
overflow: hidden;
border-radius: 12px;
background: rgba(255, 247, 230, 0.06);
padding: 0 14px;
}
.ticker__track {
display: flex;
flex-direction: column;
animation: ticker-scroll 2s linear infinite;
}
.ticker__row {
flex-shrink: 0;
height: 27px;
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, 0.78);
}
.ticker__row b {
color: #5b8cf8;
font-weight: 800;
}
@keyframes ticker-scroll {
from {
transform: translateY(0);
}
to {
transform: translateY(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.ticker__track {
animation: none;
transform: translateY(0);
}
}import type { CSSProperties } from "react";
import styles from "./VerticalTicker.module.scss";
export type TickerRow = { symbol: string; price: string; change: string; up: boolean };
export type VerticalTickerProps = {
rows?: TickerRow[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const defaultRows: TickerRow[] = [
{ symbol: "AAPL", price: "189.42", change: "▲1.2%", up: true },
{ symbol: "GOOG", price: "172.05", change: "▼0.4%", up: false },
{ symbol: "MSFT", price: "428.90", change: "▲0.8%", up: true },
{ symbol: "NVDA", price: "121.77", change: "▲2.1%", up: true },
];
export default function VerticalTicker({
rows = defaultRows,
duration = 12000,
easing = "linear",
color = "#2f6df6",
}: VerticalTickerProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const doubled = [...rows, ...rows];
return (
<div className={styles.ticker} style={vars}>
<div className={styles.track}>
{doubled.map((r, i) => (
<span key={r.symbol + i} className={styles.row} aria-hidden={i >= rows.length}>
{r.symbol} <b>{r.price}</b> {r.change}
</span>
))}
</div>
</div>
);
}05 · Vertical ticker — Measured area 9.767% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.ticker__track {
display: flex;
flex-direction: column;
animation: ticker-scroll 12s linear infinite;
}
@keyframes ticker-scroll {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
06Dual direction rows
How it works
Two rows — DESIGN, MOTION, LAYOUT running forward and CSS, SCSS, REACT running backward — share one marquee-scroll animation, and only the second row adds animation-direction: reverse.
Where it fits
No second @keyframes block exists anywhere in the file; direction is the only property that differs between the two tracks.

<main class="stage">
<div class="marquee">
<div class="marquee__row marquee__row--fwd">
<div class="marquee__track">
<span class="marquee__chip">DESIGN</span>
<span class="marquee__chip">MOTION</span>
<span class="marquee__chip">LAYOUT</span>
<span class="marquee__chip" aria-hidden="true">DESIGN</span>
<span class="marquee__chip" aria-hidden="true">MOTION</span>
<span class="marquee__chip" aria-hidden="true">LAYOUT</span>
</div>
</div>
<div class="marquee__row marquee__row--rev">
<div class="marquee__track">
<span class="marquee__chip">CSS</span>
<span class="marquee__chip">SCSS</span>
<span class="marquee__chip">REACT</span>
<span class="marquee__chip" aria-hidden="true">CSS</span>
<span class="marquee__chip" aria-hidden="true">SCSS</span>
<span class="marquee__chip" aria-hidden="true">REACT</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: linear;
$color: $stage-ink;
@include stage($stage-yellow);
.marquee { width: min(320px, 84vw); display: grid; gap: 12px; }
.marquee__row { overflow: hidden; }
.marquee__track {
display: flex;
width: max-content;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.marquee__row--fwd .marquee__track { animation-name: marquee-scroll; }
// 같은 애니메이션을 그대로 쓰되 animation-direction 만 뒤집는다 — 별도 keyframes 를 만들 필요가 없다
.marquee__row--rev .marquee__track { animation-name: marquee-scroll; animation-direction: reverse; }
.marquee__chip {
margin-right: 12px; // 트랙의 gap 대신 — 세트 폭이 정확히 절반이 되어 -50% 이음새가 0px 이 된다
flex-shrink: 0;
display: grid;
place-items: center;
height: 32px;
padding: 0 14px;
border-radius: $r-pill;
background: $color;
color: $stage-yellow;
font-weight: 700; font-size: 12px; line-height: 1; font-family: inherit;
letter-spacing: .04em;
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.marquee__track { animation: none; transform: translateX(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.marquee {
width: min(320px, 84vw);
display: grid;
gap: 12px;
}
.marquee__row {
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.marquee__row--fwd .marquee__track {
animation-name: marquee-scroll;
}
.marquee__row--rev .marquee__track {
animation-name: marquee-scroll;
animation-direction: reverse;
}
.marquee__chip {
margin-right: 12px;
flex-shrink: 0;
display: grid;
place-items: center;
height: 32px;
padding: 0 14px;
border-radius: 999px;
background: #17141a;
color: #ffd23f;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
@keyframes marquee-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee__track {
animation: none;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./DualDirectionMarquee.module.scss";
export type DualDirectionMarqueeProps = {
forward?: string[];
reverse?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function Row({ items, reverse }: { items: string[]; reverse?: boolean }) {
return (
<div className={styles.row}>
<div className={reverse ? `${styles.track} ${styles.reverse}` : styles.track}>
{items.map((it) => (
<span key={it} className={styles.chip}>{it}</span>
))}
{items.map((it) => (
<span key={"dup-" + it} className={styles.chip} aria-hidden="true">{it}</span>
))}
</div>
</div>
);
}
export default function DualDirectionMarquee({
forward = ["DESIGN", "MOTION", "LAYOUT"],
reverse = ["CSS", "SCSS", "REACT"],
duration = 12000,
easing = "linear",
color = "#17141a",
}: DualDirectionMarqueeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.marquee} style={vars}>
<Row items={forward} />
<Row items={reverse} reverse />
</div>
);
}06 · Dual direction rows — Measured area 14.222% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.marquee__track {
animation-name: marquee-scroll;
animation-duration: 12s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.marquee__row--rev .marquee__track {
animation-direction: reverse;
}
07Variable-speed flow
How it works
The same -50% distance from item 01 runs under cubic-bezier(.65, 0, .35, 1) instead of linear, so the loop slows down right at the seam and speeds up through the middle of the lap.
Where it fits
Nothing about the duplication or the travel distance changes — only the rhythm a reader feels while the row is in motion.

<main class="stage">
<div class="marquee">
<div class="marquee__track">
<span class="marquee__chip"></span>
<span class="marquee__chip"></span>
<span class="marquee__chip"></span>
<span class="marquee__chip"></span>
<span class="marquee__chip"></span>
<span class="marquee__chip" aria-hidden="true"></span>
<span class="marquee__chip" aria-hidden="true"></span>
<span class="marquee__chip" aria-hidden="true"></span>
<span class="marquee__chip" aria-hidden="true"></span>
<span class="marquee__chip" aria-hidden="true"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: cubic-bezier(.65, 0, .35, 1); // 비선형 — 이음매 근처는 느리고 중간은 빠르다
$color: $subject-cream;
@include stage($stage-orange);
.marquee {
width: min(300px, 80vw);
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
animation: marquee-scroll $duration $easing infinite;
}
.marquee__chip {
margin-right: 22px; // 트랙의 gap 대신 — 세트 폭이 정확히 절반이 되어 -50% 이음새가 0px 이 된다
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 50%;
background: $color;
}
// linear 대신 커스텀 cubic-bezier 를 타이밍 함수로 써도 -50% 이동량은 그대로다.
// 이음매(0%·100%)에선 느리게 붙고 구간 중간에서 빨라지는 리듬만 달라진다 — JS 없이, 한 줄로
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.marquee__track { animation: none; transform: translateX(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.marquee {
width: min(300px, 80vw);
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
animation: marquee-scroll 2s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
.marquee__chip {
margin-right: 22px;
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 50%;
background: #fff7e6;
}
@keyframes marquee-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee__track {
animation: none;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./VariableSpeedMarquee.module.scss";
export type VariableSpeedMarqueeProps = {
count?: number;
duration?: number;
/** 비선형 타이밍 함수 — 이음매 근처는 느리고 가운데는 빠르다 */
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function VariableSpeedMarquee({
count = 5,
duration = 9000,
easing = "cubic-bezier(.65,0,.35,1)",
color = "#fff7e6",
}: VariableSpeedMarqueeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const dots = Array.from({ length: count * 2 });
return (
<div className={styles.marquee} style={vars}>
<div className={styles.track}>
{dots.map((_, i) => (
<span key={i} className={styles.chip} aria-hidden={i >= count} />
))}
</div>
</div>
);
}07 · Variable-speed flow — Measured area 5.417% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.marquee__track {
animation: marquee-scroll 9s
cubic-bezier(.65, 0, .35, 1) infinite;
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
083D curved marquee
How it works
A perspective on the stage and preserve-3d on the track let five blank cards each carry their own rotateY and translateZ step, bowing the strip into a shallow cylinder instead of a flat plane. A Sass @for loop repeats the same five-step angle table across the cloned half of the track.
Where it fits
That way, the curve never flattens out or snaps at the seam the way a one-off, hand-written set of angles would.

<main class="stage">
<div class="marquee">
<div class="marquee__track">
<span class="marquee__card"></span>
<span class="marquee__card"></span>
<span class="marquee__card"></span>
<span class="marquee__card"></span>
<span class="marquee__card"></span>
<span class="marquee__card" aria-hidden="true"></span>
<span class="marquee__card" aria-hidden="true"></span>
<span class="marquee__card" aria-hidden="true"></span>
<span class="marquee__card" aria-hidden="true"></span>
<span class="marquee__card" aria-hidden="true"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue;
@include stage($stage-paper);
.stage { perspective: 420px; }
.marquee {
width: min(300px, 80vw);
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
transform-style: preserve-3d;
animation: marquee-scroll $duration $easing infinite;
}
.marquee__card {
margin-right: 8px; // 트랙의 gap 대신 — 세트 폭이 정확히 절반이 되어 -50% 이음새가 0px 이 된다
flex-shrink: 0;
width: 40px;
height: 64px;
border-radius: $r-control;
background: $color;
box-shadow: $shadow-press;
transform-style: preserve-3d;
}
// 카드 5장을 부채꼴로 기울여 원통 벽처럼 보이게 한다 — 같은 각도 표를 복제본(6~10번째)에도 그대로 반복해
// 이음매에서 곡률이 끊기지 않는다
@for $i from 1 through 10 {
$step: ($i - 1) % 5;
.marquee__card:nth-child(#{$i}) {
transform: rotateY(#{($step - 2) * 22}deg) translateZ(#{-1 * (2 - if($step > 2, 4 - $step, $step)) * 20}px);
}
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.marquee__track { animation: none; transform: translateX(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
perspective: 420px;
}
.marquee {
width: min(300px, 80vw);
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
transform-style: preserve-3d;
animation: marquee-scroll 2s linear infinite;
}
.marquee__card {
margin-right: 8px;
flex-shrink: 0;
width: 40px;
height: 64px;
border-radius: 12px;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform-style: preserve-3d;
}
.marquee__card:nth-child(1) {
transform: rotateY(-44deg) translateZ(-40px);
}
.marquee__card:nth-child(2) {
transform: rotateY(-22deg) translateZ(-20px);
}
.marquee__card:nth-child(3) {
transform: rotateY(0deg) translateZ(-0px);
}
.marquee__card:nth-child(4) {
transform: rotateY(22deg) translateZ(-20px);
}
.marquee__card:nth-child(5) {
transform: rotateY(44deg) translateZ(-40px);
}
.marquee__card:nth-child(6) {
transform: rotateY(-44deg) translateZ(-40px);
}
.marquee__card:nth-child(7) {
transform: rotateY(-22deg) translateZ(-20px);
}
.marquee__card:nth-child(8) {
transform: rotateY(0deg) translateZ(-0px);
}
.marquee__card:nth-child(9) {
transform: rotateY(22deg) translateZ(-20px);
}
.marquee__card:nth-child(10) {
transform: rotateY(44deg) translateZ(-40px);
}
@keyframes marquee-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee__track {
animation: none;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./Curved3DMarquee.module.scss";
export type Curved3DMarqueeProps = {
count?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function Curved3DMarquee({
count = 5,
duration = 12000,
easing = "linear",
color = "#2f6df6",
}: Curved3DMarqueeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cards = Array.from({ length: count * 2 });
return (
<div className={styles.stage3d}>
<div className={styles.marquee} style={vars}>
<div className={styles.track}>
{cards.map((_, i) => (
<span key={i} className={styles.card} aria-hidden={i >= count} />
))}
</div>
</div>
</div>
);
}08 · 3D curved marquee — Measured area 13.333% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.stage { perspective: 420px; }
.marquee__track {
display: flex;
transform-style: preserve-3d;
animation: marquee-scroll 12s linear infinite;
}
@for $i from 1 through 10 {
$step: ($i - 1) % 5;
.marquee__card:nth-child(#{$i}) {
transform: rotateY(#{($step - 2) * 22}deg);
}
}
09Static fallback for reduced motion
How it works
A toggle switches between the running track and the frozen, single-pass list that prefers-reduced-motion: reduce actually serves to a real visitor — News, Update, Event, no clones, no motion.
Where it fits
A status label flips between "Playing" and "Paused" alongside it, so the difference is something a reader can see rather than take on faith.

<main class="stage">
<div class="panel is-demo">
<div class="panel__row">
<div class="marquee">
<div class="marquee__track">
<span class="marquee__chip">News</span>
<span class="marquee__chip">Update</span>
<span class="marquee__chip">Event</span>
<span class="marquee__chip" aria-hidden="true">News</span>
<span class="marquee__chip" aria-hidden="true">Update</span>
<span class="marquee__chip" aria-hidden="true">Event</span>
</div>
</div>
<span class="panel__state">
<span class="panel__state-text panel__state-text--play">Playing</span>
<span class="panel__state-text panel__state-text--pause">Paused</span>
</span>
</div>
<label class="panel__toggle" aria-hidden="true">
<span class="panel__toggle-track"><span class="panel__toggle-knob"></span></span>
<span class="panel__toggle-label">Reduce motion</span>
</label>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
$fg: $subject-cream;
@include stage($stage-ink);
.panel {
display: grid;
gap: 14px;
justify-items: center;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.panel__row {
display: grid;
gap: 8px;
justify-items: center;
}
.marquee {
width: min(240px, 70vw);
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
}
.marquee__chip {
margin-right: 14px; // 트랙의 gap 대신 — 세트 폭이 정확히 절반이 되어 -50% 이음새가 0px 이 된다
flex-shrink: 0;
display: grid;
place-items: center;
height: 30px;
padding: 0 12px;
border-radius: $r-control;
background: rgba($fg, .1);
color: $fg;
font-weight: 700; font-size: 12px; line-height: 1; font-family: inherit;
}
.panel__state { position: relative; width: 100%; min-width: 84px; height: 16px; }
.panel__state-text {
position: absolute;
inset: 0;
width: 100%;
text-align: center;
white-space: nowrap;
font-weight: 700; font-size: 12px; line-height: 16px; font-family: inherit;
color: rgba($fg, .7);
}
.panel__toggle { display: flex; align-items: center; gap: 8px; }
.panel__toggle-track {
position: relative;
width: 36px;
height: 20px;
border-radius: $r-pill;
background: rgba($fg, .18);
}
.panel__toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: $color;
}
.panel__toggle-label {
font-weight: 600; font-size: 11px; line-height: 1; font-family: inherit;
color: rgba($fg, .68);
}
// 트랙은 토글이 꺼진 55% 구간까지만 움직이고, 켜진 뒤로는 그 자리에 그대로 멈춘다 —
// 실제 prefers-reduced-motion: reduce 사용자에게 나가는 화면과 같은 정지 상태다
.panel.is-demo .marquee__track { animation: marquee-toggle $duration linear infinite; }
.panel.is-demo .panel__toggle-knob { animation: toggle-knob $duration $easing infinite; }
.panel.is-demo .panel__toggle-track { animation: toggle-track $duration $easing infinite; }
.panel.is-demo .panel__state-text--play { animation: state-play $duration steps(1) infinite; }
.panel.is-demo .panel__state-text--pause { animation: state-pause $duration steps(1) infinite; }
@keyframes marquee-toggle {
0% { transform: translateX(0); }
55% { transform: translateX(-30%); }
100% { transform: translateX(-30%); }
}
@keyframes toggle-knob {
0%, 50% { transform: translateX(0); }
70%, 100% { transform: translateX(16px); }
}
@keyframes toggle-track {
0%, 50% { background: rgba($fg, .18); }
70%, 100% { background: rgba($color, .38); }
}
@keyframes state-play {
0%, 50% { opacity: 1; }
60%, 100% { opacity: 0; }
}
@keyframes state-pause {
0%, 50% { opacity: 0; }
60%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.marquee__track, .panel__toggle-knob, .panel__toggle-track,
.panel__state-text--play, .panel__state-text--pause {
animation: none !important;
}
.panel.is-demo .marquee__track { transform: translateX(-30%); }
.panel.is-demo .panel__toggle-knob { transform: translateX(16px); }
.panel.is-demo .panel__toggle-track { background: rgba($color, .38); }
.panel.is-demo .panel__state-text--play { opacity: 0; }
.panel.is-demo .panel__state-text--pause { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: grid;
gap: 14px;
justify-items: center;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.panel__row {
display: grid;
gap: 8px;
justify-items: center;
}
.marquee {
width: min(240px, 70vw);
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
}
.marquee__chip {
margin-right: 14px;
flex-shrink: 0;
display: grid;
place-items: center;
height: 30px;
padding: 0 12px;
border-radius: 12px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.panel__state {
position: relative;
width: 100%;
min-width: 84px;
height: 16px;
}
.panel__state-text {
position: absolute;
inset: 0;
width: 100%;
text-align: center;
white-space: nowrap;
font-weight: 700;
font-size: 12px;
line-height: 16px;
font-family: inherit;
color: rgba(255, 247, 230, 0.7);
}
.panel__toggle {
display: flex;
align-items: center;
gap: 8px;
}
.panel__toggle-track {
position: relative;
width: 36px;
height: 20px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.18);
}
.panel__toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #2f6df6;
}
.panel__toggle-label {
font-weight: 600;
font-size: 11px;
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, 0.68);
}
.panel.is-demo .marquee__track {
animation: marquee-toggle 2s linear infinite;
}
.panel.is-demo .panel__toggle-knob {
animation: toggle-knob 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.panel.is-demo .panel__toggle-track {
animation: toggle-track 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.panel.is-demo .panel__state-text--play {
animation: state-play 2s steps(1) infinite;
}
.panel.is-demo .panel__state-text--pause {
animation: state-pause 2s steps(1) infinite;
}
@keyframes marquee-toggle {
0% {
transform: translateX(0);
}
55% {
transform: translateX(-30%);
}
100% {
transform: translateX(-30%);
}
}
@keyframes toggle-knob {
0%, 50% {
transform: translateX(0);
}
70%, 100% {
transform: translateX(16px);
}
}
@keyframes toggle-track {
0%, 50% {
background: rgba(255, 247, 230, 0.18);
}
70%, 100% {
background: rgba(47, 109, 246, 0.38);
}
}
@keyframes state-play {
0%, 50% {
opacity: 1;
}
60%, 100% {
opacity: 0;
}
}
@keyframes state-pause {
0%, 50% {
opacity: 0;
}
60%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.marquee__track, .panel__toggle-knob, .panel__toggle-track,
.panel__state-text--play, .panel__state-text--pause {
animation: none !important;
}
.panel.is-demo .marquee__track {
transform: translateX(-30%);
}
.panel.is-demo .panel__toggle-knob {
transform: translateX(16px);
}
.panel.is-demo .panel__toggle-track {
background: rgba(47, 109, 246, 0.38);
}
.panel.is-demo .panel__state-text--play {
opacity: 0;
}
.panel.is-demo .panel__state-text--pause {
opacity: 1;
}
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ReducedMotionMarquee.module.scss";
export type ReducedMotionMarqueeProps = {
items?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ReducedMotionMarquee({
items = ["공지", "업데이트", "이벤트"],
duration = 12000,
easing = "linear",
color = "#2f6df6",
}: ReducedMotionMarqueeProps) {
const [reduced, setReduced] = useState(false);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
animationPlayState: reduced ? "paused" : "running",
} as Vars;
return (
<div className={styles.panel}>
<div className={styles.marquee}>
<div className={styles.track} style={vars}>
{items.map((it) => (
<span key={it} className={styles.chip}>{it}</span>
))}
{items.map((it) => (
<span key={"dup-" + it} className={styles.chip} aria-hidden="true">{it}</span>
))}
</div>
</div>
<span className={styles.state}>{reduced ? "정지됨" : "재생 중"}</span>
<button type="button" className={styles.toggle} aria-pressed={reduced} onClick={() => setReduced((v) => !v)}>
<span className={styles.switchTrack} data-on={reduced}><span className={styles.knob} /></span>
동작 줄이기
</button>
</div>
);
}09 · Static fallback for reduced motion — Measured area 5.672% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
@media (prefers-reduced-motion: reduce) {
.marquee__track {
animation: none !important;
transform: translateX(-30%);
}
}
Where it breaks — the traps
Two problems in this set don't show up just by watching the loop play — one needs a screen reader to catch, the other needs a ruler.
The clone a screen reader reads twice
Item 02's duplicated wordmark row renders NORTH, ACME, ORBIT, VELA, and KADE on screen exactly once each, but the DOM holds every name twice so the seam can line up. A screen reader that isn't told to skip the clone announces all ten, turning five partners into what sounds like a company twice its real size. MDN's aria-hidden reference explains why nothing looks wrong on screen while this is happening: the attribute only removes an element from the accessibility tree; it never hides anything visually, so a sighted check of the page misses the bug completely. Marking every span in the cloned half with aria-hidden="true", the same pattern all nine demos use, fixes it without moving a single pixel.
The half-gap at the seam
The seamless-loop math itself hides a second problem that only shows up once you measure the seam instead of eyeballing it. Twelve chips split into two sets of six only create eleven gaps between them, not twelve, so putting the spacing on the track's gap property leaves translateX(-50%) a half-gap short of the clone's real position. Every track above that spaced its clones with gap — everything except item 05's vertical rows, which already touch with no gap between them — jumped at that seam on every restart, by an amount that measured 4px to 20px depending on the item's own spacing value. caniuse's flexbox gap table confirms every browser this site targets supports gap on flex containers, so the fix isn't a compatibility workaround.
The fix
It's margin-right on each individual chip instead of gap on the track: the spacing gets counted inside each set of six rather than between all twelve, so the first six chips land at exactly half the track's total width. Switching every affected demo above to that approach is exactly the fix bundled into the archive that unlocks with gcjydr57, typed in lowercase precisely as it reads in this sentence, digits and all.
Accessibility
Not the same stillness
All nine cancel their motion under prefers-reduced-motion: reduce, but they don't all land on the same kind of stillness. Items 01, 02, 04, 05, 06, 07, and 08 simply drop the animation and hold the track's last computed transform, so a reader sees a complete, unmoving row rather than a half-scrolled one. Item 03 needs no separate reduced-motion rule at all — the same :hover and :focus-within pause that helps a sighted mouse user also satisfies a vestibular one. Pausing on interaction already removes the motion a reduced-motion setting exists to prevent.
The one that shows the setting
Item 09 is the one built to demonstrate the setting rather than just obey it: flipping its toggle previews the exact frozen, single-pass list a real prefers-reduced-motion: reduce visitor gets, without needing to change an operating-system setting just to see it. MDN's page on the media feature covers which platform settings trigger it and how browsers read that preference.
Every other scroll-driven demo on this site sits behind the CSS category page, and what this project does and doesn't sell is laid out on the about page.
FAQ
Do I need JavaScript to build a css marquee infinite loop?
No — all nine tracks above move purely through a CSS @keyframes animation and a transform, with nothing reading scroll position or driving the loop from script. The zip in this article also ships a React version of each pattern for teams already working in components. The underlying animation in every one of them is still plain CSS, not a JS interval or a requestAnimationFrame loop.
Why does an infinite scrolling marquee css track jump at the seam?
The two most common causes are a duplicate count that isn't exactly double the original content, and a transform distance that isn't exactly -50% to match it. Either one leaves a visible gap or overlap once the loop restarts. A quieter version of the same stutter comes from gap on the flex container itself — the exact bug covered above. The count and the -50% distance can both be correct and the loop still jumps a few pixels at the seam, because gap only creates space between items: one fewer gap than the item count, not a trailing space after the last one in each set. Swapping gap on the track for margin-right on each item removes that jump.
Will a css marquee infinite loop pause automatically for users who prefer reduced motion?
Only if the CSS explicitly says so — nothing about animation: infinite respects a visitor's settings on its own. Each of the nine demos above carries its own @media (prefers-reduced-motion: reduce) block, or in item 03's case a pause trigger that already covers it. Leaving that block out means the track keeps scrolling regardless of what the reader's operating system requests.









