02가로로 흐르는 티커 테이프
같은 줄을 두 벌 이어 붙여 translateX 로 밀고, 포인터를 올리면 animation-play-state 가 테이프를 세운다
쓰임서비스 상단 고정 시세 띠, 대형 화면 로비 모니터
translateXanimation-play-statemargin-right

<main class="stage">
<div class="tt is-demo" id="tt">
<div class="tt__panel">
<div class="tt__head">
<span class="tt__pulse" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m96 40l33.52 88H56Zm104 88h-70.48L160 208Z" opacity=".2"/><path d="M240 128a8 8 0 0 1-8 8h-27.06l-37.78 75.58A8 8 0 0 1 160 216h-.4a8 8 0 0 1-7.08-5.14L95.35 60.76l-32.07 70.55A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.85l37.87-83.31a8 8 0 0 1 14.76.46l57.51 151l31.85-63.71A8 8 0 0 1 200 120h32a8 8 0 0 1 8 8"/></g></svg></span>
<p class="tt__title">실시간 시세</p>
<span class="tt__hint">올려 두면 멈춤</span>
</div>
<!-- 테이프 창(overflow:hidden)과 흐르는 트랙을 분리한다. transform 은 언제나 트랙에만 건다 -->
<div class="tt__tape" id="ttTape" tabindex="0" aria-label="시세 테이프">
<div class="tt__track" id="ttTrack">
<!-- 같은 줄을 두 벌 이어 붙였다. 두 벌의 폭이 픽셀 단위로 같아야 -50% 가 정확히 한 벌이라
한 바퀴가 이음매 없이 이어진다. 그래서 간격은 트랙의 flex gap 이 아니라 항목마다 margin-right 다 -->
<div class="tt__half" data-half="0">
<span class="tt__item" data-i="0"><span class="tt__sym">NOVA</span><span class="tt__price">0</span><span class="tt__chg">0</span></span>
<span class="tt__item" data-i="1"><span class="tt__sym">ORBIT</span><span class="tt__price">0</span><span class="tt__chg">0</span></span>
<span class="tt__item" data-i="2"><span class="tt__sym">LUMEN</span><span class="tt__price">0</span><span class="tt__chg">0</span></span>
</div>
<div class="tt__half" data-half="1" aria-hidden="true">
<span class="tt__item" data-i="0"><span class="tt__sym">NOVA</span><span class="tt__price">0</span><span class="tt__chg">0</span></span>
<span class="tt__item" data-i="1"><span class="tt__sym">ORBIT</span><span class="tt__price">0</span><span class="tt__chg">0</span></span>
<span class="tt__item" data-i="2"><span class="tt__sym">LUMEN</span><span class="tt__price">0</span><span class="tt__chg">0</span></span>
</div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 가로로 흐르는 티커 테이프 — 같은 줄을 두 벌 이어 붙이고 트랙을 translateX(-50%) 로 민다.
// -50% 는 트랙 폭의 절반이라 "정확히 한 벌"이어야 한 바퀴가 이음매 없이 이어진다. 그래서 간격은
// 트랙의 flex gap 이 아니라 항목마다의 margin-right 로 준다 — gap 은 2N 개 사이에 2N-1 번만 생겨
// -50% 가 gap 절반만큼 모자라고 한 바퀴마다 테이프가 튄다 (BD15, 028 실측 4~20px).
// 028(세로 마퀴)과 갈리는 지점: 028 은 세로로 도는 정지 목록이고, 이건 가로로 흐르면서 값이 실시간으로 갱신된다.
// 루프 안 플래시 0회 — 깜빡이지 않고 흐르기만 한다.
$duration: 10s; // 실사용 속도(초당 45px 쯤). 미리보기는 아래 .is-demo 가 한 바퀴를 2초로 배속한다
$easing: linear; // 등속 — 완급을 주면 글자를 따라 읽을 수 없다
$color: $subject-cream;
@include stage($stage-ink);
.tt { display: flex; justify-content: center; width: 100%; }
// 폭은 100% 가 아니라 뷰포트 단위로 못박는다 — overflow:hidden 은 잘라 보여 줄 뿐 요소의 max-content 를
// 줄이지 않아서, 폭이 100%(불확정)면 무대 격자가 트랙의 max-content(1,000px 넘음)만큼 벌어진다 (실측 480px 무대에서 1,047px)
.tt__panel {
width: min(340px, 84vw); background: $tick-panel; border-radius: $r-card;
box-shadow: $shadow-raised; padding: $sp-3 0 $sp-2; overflow: hidden;
}
.tt__head { display: flex; align-items: center; gap: $sp-2; padding: 0 $sp-3 $sp-3; }
// 아이콘은 Phosphor Duotone SVG 를 그대로 인라인했다 — 색은 currentColor 로 물려받는다
.tt__pulse { display: inline-flex; width: 14px; height: 14px; color: $tick-up; }
.tt__pulse svg { display: block; width: 100%; height: 100%; }
.tt__title { margin: 0; font-weight: 800; font-size: clamp(11px, 3vw, 13px); color: $color; }
.tt__hint { margin-left: auto; font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); color: $tick-mute; }
// 테이프 창(overflow:hidden)과 흐르는 트랙을 분리한다 — transform 은 언제나 트랙에만 건다
.tt__tape {
overflow: hidden; padding: $sp-2 0;
border-top: 1px solid rgba(154, 147, 166, .24);
border-bottom: 1px solid rgba(154, 147, 166, .24);
}
.tt__tape:focus-visible { outline: 2px solid $tick-up; outline-offset: -2px; }
.tt__track {
display: flex; width: max-content;
animation-name: ttSlide;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.tt__half { display: flex; }
.tt__item {
display: inline-flex; align-items: center; flex-shrink: 0; white-space: nowrap;
margin-right: $sp-6; // 트랙의 gap 대신 — 한 벌의 폭이 (칸 + 간격) × N 으로 딱 떨어진다
}
.tt__sym {
margin-right: $sp-2; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px);
color: $tick-mute; letter-spacing: .04em;
}
.tt__price {
margin-right: $sp-2; font-weight: 800; font-size: clamp(11px, 3.2vw, 14px); line-height: 1.5;
color: $color; font-variant-numeric: tabular-nums;
}
.tt__chg { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); line-height: 1.5; font-variant-numeric: tabular-nums; }
.tt__chg.is-up { color: $tick-up; }
.tt__chg.is-down { color: $tick-down; }
@keyframes ttSlide {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
// 포인터·터치·키보드 어느 쪽으로 들어와도 테이프가 선다 — 읽는 중에 글자가 지나가지 않게.
// 멈추는 건 animation-play-state 하나뿐이라 위치를 JS 로 붙잡을 필요가 없다
.tt__tape:hover .tt__track,
.tt:focus-within .tt__track { animation-play-state: paused; }
// 격자 미리보기: 한 바퀴를 2초에 맞춰 gif 루프와 이음매를 겹친다 (24프레임이 정확히 한 바퀴)
.tt.is-demo .tt__track { animation-duration: $dur-loop; }
@media (max-width: 340px) {
.tt__panel { padding: $sp-2 0 $sp-1; }
.tt__head { padding: 0 $sp-2 $sp-2; }
.tt__tape { padding: $sp-1 0; }
.tt__item { margin-right: $sp-5; }
}
@media (prefers-reduced-motion: reduce) {
// 흐름을 멈추고 값만 갱신한다 — 테이프는 맨 앞에 선 채로 숫자만 바뀐다
.tt__track,
.tt.is-demo .tt__track { animation: none !important; transform: translateX(0) !important; }
}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);
}
.tt {
display: flex;
justify-content: center;
width: 100%;
}
.tt__panel {
width: min(340px, 84vw);
background: #231f28;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px 0 8px;
overflow: hidden;
}
.tt__head {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px 12px;
}
.tt__pulse {
display: inline-flex;
width: 14px;
height: 14px;
color: #3ddc97;
}
.tt__pulse svg {
display: block;
width: 100%;
height: 100%;
}
.tt__title {
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3vw, 13px);
color: #fff7e6;
}
.tt__hint {
margin-left: auto;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
color: #9a93a6;
}
.tt__tape {
overflow: hidden;
padding: 8px 0;
border-top: 1px solid rgba(154, 147, 166, 0.24);
border-bottom: 1px solid rgba(154, 147, 166, 0.24);
}
.tt__tape:focus-visible {
outline: 2px solid #3ddc97;
outline-offset: -2px;
}
.tt__track {
display: flex;
width: max-content;
animation-name: ttSlide;
animation-duration: 10s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tt__half {
display: flex;
}
.tt__item {
display: inline-flex;
align-items: center;
flex-shrink: 0;
white-space: nowrap;
margin-right: 24px;
}
.tt__sym {
margin-right: 8px;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #9a93a6;
letter-spacing: 0.04em;
}
.tt__price {
margin-right: 8px;
font-weight: 800;
font-size: clamp(11px, 3.2vw, 14px);
line-height: 1.5;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.tt__chg {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1.5;
font-variant-numeric: tabular-nums;
}
.tt__chg.is-up {
color: #3ddc97;
}
.tt__chg.is-down {
color: #ff7a5e;
}
@keyframes ttSlide {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.tt__tape:hover .tt__track,
.tt:focus-within .tt__track {
animation-play-state: paused;
}
.tt.is-demo .tt__track {
animation-duration: 2s;
}
@media (max-width: 340px) {
.tt__panel {
padding: 8px 0 4px;
}
.tt__head {
padding: 0 8px 8px;
}
.tt__tape {
padding: 4px 0;
}
.tt__item {
margin-right: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt__track,
.tt.is-demo .tt__track {
animation: none !important;
transform: translateX(0) !important;
}
}// 가짜 시세는 시드 PRNG(mulberry32)로 만든다 — 외부 API·키가 필요 없고, 같은 시드면 누가 열어도 같은 흐름이다.
// 심볼도 실존 종목이 아니다.
var SEED = 296;
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
var rand = mulberry32(SEED);
var START = [{ sym: 'NOVA', base: 64200 }, { sym: 'ORBIT', base: 18450 }, { sym: 'LUMEN', base: 7320 }];
var LATER = [{ sym: 'KEEL', base: 132900 }, { sym: 'AXIOM', base: 38400 }, { sym: 'VERDE', base: 9160 }];
var PX_PER_SEC = 45; // 실사용 흐름 속도. 칸이 늘어도 이 속도를 지키려고 한 바퀴 시간을 다시 잡는다
var tt = document.getElementById('tt');
var track = document.getElementById('ttTrack');
var halves = [].slice.call(track.querySelectorAll('.tt__half'));
var items = START.map(function (s) {
// 전일 대비 등락을 한 번 굴려 오늘 가격을 만든다 — 첫 화면부터 등락률이 0 이 아니다
var day = (rand() - 0.5) * 0.06;
return { sym: s.sym, base: s.base, price: Math.round(s.base * (1 + day)) };
});
// 숫자는 자리가 흔들리면 읽기 어렵다 — 천 단위 구분자까지 넣어 고정 폭으로 그린다
function money(n) { return n.toLocaleString('en-US'); }
function pct(v) { return (v >= 0 ? '+' : '−') + Math.abs(v).toFixed(2) + '%'; }
function arrow(v) { return v >= 0 ? '▲' : '▼'; }
// 방향은 색만으로 전하지 않는다 — 화살표와 부호를 늘 같이 적는다.
// 두 벌 모두 같은 값으로 칠해야 이음매에서 앞뒤 값이 갈리지 않는다
function paint(k) {
var it = items[k], chg = (it.price - it.base) / it.base * 100;
halves.forEach(function (h) {
var cell = h.querySelector('[data-i="' + k + '"]');
if (!cell) { return; }
cell.querySelector('.tt__price').textContent = money(it.price);
var c = cell.querySelector('.tt__chg');
c.textContent = arrow(chg) + ' ' + pct(chg);
c.className = 'tt__chg ' + (chg >= 0 ? 'is-up' : 'is-down');
});
}
items.forEach(function (it, k) { paint(k); });
// 격자 미리보기(.is-demo) 동안에는 CSS 키프레임만 돌고 JS 는 DOM 을 안 건드린다 — 24프레임이 같은 순간을
// 재현해야 측정이 흔들리지 않는다. 포인터·터치·키보드 중 하나가 들어오면 그때부터 시드 시세가 실제로 갱신된다
function live() { if (tt.classList.contains('is-demo')) { tt.classList.remove('is-demo'); } }
['pointerdown', 'pointermove', 'focusin'].forEach(function (ev) { tt.addEventListener(ev, live); });
var calm = window.matchMedia('(prefers-reduced-motion: reduce)');
var pending = [];
var added = 0;
// 새 종목이 상장하면 테이프 오른쪽 끝에 한 칸이 덧붙는다. 넣을 때는 반드시 두 벌에 같이 넣는다 —
// 한쪽만 늘면 두 벌의 폭이 달라져 -50% 가 어긋나고 한 바퀴마다 테이프가 튄다
function makeCell(k) {
var el = document.createElement('span');
el.className = 'tt__item';
el.setAttribute('data-i', String(k));
el.innerHTML = '<span class="tt__sym"></span><span class="tt__price"></span><span class="tt__chg"></span>';
el.querySelector('.tt__sym').textContent = items[k].sym;
return el;
}
function queueNew() {
if (added >= LATER.length) { return; }
var s = LATER[added];
added += 1;
items.push({ sym: s.sym, base: s.base, price: Math.round(s.base * (1 + (rand() - 0.5) * 0.06)) });
pending.push(items.length - 1);
// 움직임을 끈 화면에서는 이음매를 맞출 한 바퀴가 없다 — 그 자리에서 바로 넣는다
if (calm.matches) { flush(); }
}
// 칸을 붙이면 한 벌의 폭이 늘어 흐르는 위치가 그 자리에서 튄다. 그래서 붙이는 시점을 한 바퀴가 막 끝난
// 순간(animationiteration)으로 미룬다 — 그 순간의 위치는 0 이라 폭이 바뀌어도 눈에 안 띈다.
// 사람이 테이프를 세워 둔 동안에는 이 사건이 안 일어나니 읽는 중에 줄이 바뀌지도 않는다
function flush() {
if (!pending.length) { return; }
pending.forEach(function (k) {
halves.forEach(function (h) { h.appendChild(makeCell(k)); });
paint(k);
});
pending = [];
// 거리가 늘었으니 시간도 그만큼 늘려 초당 흐름 속도를 지킨다. 위치가 0 인 이 순간에 다시 시작한다
var w = halves[0].getBoundingClientRect().width;
track.style.animationName = 'none';
void track.offsetWidth;
track.style.animationDuration = (w / PX_PER_SEC).toFixed(2) + 's';
track.style.animationName = '';
}
track.addEventListener('animationiteration', flush);
// 한 번에 한 칸만 바꾼다. 700ms 간격이라 한 종목은 2초에 한 번꼴로 갱신된다
var cursor = 0;
var ticks = 0;
var timer = setInterval(function () {
if (tt.classList.contains('is-demo')) { return; }
if (document.visibilityState === 'hidden') { return; }
var it = items[cursor];
var step = Math.round((rand() - 0.48) * it.price * 0.004);
if (step === 0) { step = rand() < 0.5 ? -1 : 1; }
it.price = it.price + step;
paint(cursor);
cursor = (cursor + 1) % items.length;
ticks += 1;
if (ticks % 8 === 0) { queueNew(); }
}, 700);
// 탭을 다시 보면 현재 상태를 그대로 다시 그린다 — 안 보이는 동안 쌓아 두지 않는다
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') { items.forEach(function (it, k) { paint(k); }); }
});
window.addEventListener('pagehide', function () { clearInterval(timer); });// TickerTape — vanilla 판(run/296/demo/02-ticker-tape-horizontal/index.html) 의 React 포트.
// 같은 줄을 두 벌 이어 붙여 translateX(-50%) 로 미는 가로 티커 테이프. 포인터·터치·키보드가 들어오면
// animation-play-state 로 테이프가 서고, 시드 PRNG 가 값을 갱신하면서 새 종목 한 칸을 오른쪽 끝에 덧붙인다.
// 간격은 트랙의 flex gap 이 아니라 칸마다의 margin-right 다 — gap 은 2N 개 사이에 2N-1 번만 생겨
// -50% 가 gap 절반만큼 모자라고 한 바퀴마다 테이프가 튄다.
import { useEffect, useRef, useState } from "react";
import styles from "./TickerTape.module.scss";
export type TickerTapeProps = {
/** 격자 미리보기용 2초 루프. 포인터를 올리거나 키보드로 들어오면 꺼지고 실제 갱신이 시작된다 */
demo?: boolean;
/** 시드 — 같은 값이면 누가 열어도 같은 시세 흐름이 재현된다 */
seed?: number;
/** 흐름 속도(초당 px). 칸이 늘어도 이 속도를 지키려고 한 바퀴 시간을 다시 잡는다 */
pxPerSec?: number;
/** 값 갱신 간격(ms) */
intervalMs?: number;
};
type Box<T> = { current: T };
type Item = { sym: string; base: number; price: number };
const START: Item[] = [
{ sym: "NOVA", base: 64200, price: 64200 },
{ sym: "ORBIT", base: 18450, price: 18450 },
{ sym: "LUMEN", base: 7320, price: 7320 },
];
const LATER = [
{ sym: "KEEL", base: 132900 },
{ sym: "AXIOM", base: 38400 },
{ sym: "VERDE", base: 9160 },
];
/** 시드 PRNG — 외부 API·키가 필요 없고 심볼도 실존 종목이 아니다 */
function mulberry32(a: number) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/** 숫자는 자리가 흔들리면 읽기 어렵다 — 천 단위 구분자까지 넣어 고정 폭으로 그린다 */
const money = (n: number) => n.toLocaleString("en-US");
const pct = (v: number) => (v >= 0 ? "+" : "−") + Math.abs(v).toFixed(2) + "%";
const arrow = (v: number) => (v >= 0 ? "▲" : "▼");
export default function TickerTape({ demo = true, seed = 296, pxPerSec = 45, intervalMs = 700 }: TickerTapeProps) {
const randRef: Box<(() => number) | null> = useRef(null);
if (randRef.current === null) { randRef.current = mulberry32(seed); }
const [items, setItems] = useState<Item[]>(() => START.map((it) => {
// 전일 대비 등락을 한 번 굴려 오늘 가격을 만든다 — 첫 화면부터 등락률이 0 이 아니다
const rand = randRef.current as () => number;
return { ...it, price: Math.round(it.base * (1 + (rand() - 0.5) * 0.06)) };
}));
const [live, setLive] = useState(!demo);
const [durationS, setDurationS] = useState(10);
const halfRef = useRef<HTMLDivElement>(null);
const pending = useRef<Item[]>([]) as Box<Item[]>;
const added = useRef(0) as Box<number>;
const cursor = useRef(0) as Box<number>;
const ticks = useRef(0) as Box<number>;
useEffect(() => {
if (!live) { return; }
const id = window.setInterval(() => {
// 탭이 안 보이는 동안은 세지 않는다 — 돌아왔을 때 밀린 갱신이 한꺼번에 터지지 않게
if (document.visibilityState === "hidden") { return; }
const rand = randRef.current as () => number;
const i = cursor.current;
setItems((old) => old.map((it, k) => {
if (k !== i) { return it; }
let step = Math.round((rand() - 0.48) * it.price * 0.004);
if (step === 0) { step = rand() < 0.5 ? -1 : 1; }
return { ...it, price: it.price + step };
}));
cursor.current = (i + 1) % Math.max(1, items.length);
ticks.current += 1;
// 새 종목은 곧바로 넣지 않고 대기줄에 세운다 — 한 바퀴가 끝나는 순간에 두 벌에 같이 들어간다
if (ticks.current % 8 === 0 && added.current < LATER.length) {
const s = LATER[added.current];
added.current += 1;
pending.current.push({ sym: s.sym, base: s.base, price: Math.round(s.base * (1 + (rand() - 0.5) * 0.06)) });
}
}, intervalMs);
return () => window.clearInterval(id);
}, [live, intervalMs, items.length]);
// 칸이 늘면 한 바퀴 거리가 늘어난다. 거리에 비례해 시간을 다시 잡아야 초당 흐름 속도가 그대로다
useEffect(() => {
const el = halfRef.current;
if (!el) { return; }
const w = el.getBoundingClientRect().width;
if (w > 0) { setDurationS(Math.max(2, w / pxPerSec)); }
}, [items.length, pxPerSec]);
// 칸을 붙이면 흐르는 위치가 그 자리에서 튄다. 그래서 붙이는 시점을 한 바퀴가 막 끝난 순간으로 미룬다 —
// 그 순간의 위치는 0 이라 두 벌의 폭이 바뀌어도 눈에 안 띈다. 테이프가 서 있으면 이 사건도 안 일어난다
const onIteration = () => {
if (!pending.current.length) { return; }
const incoming = pending.current;
pending.current = [];
setItems((old) => old.concat(incoming));
};
const rootCls = [styles.root, live ? "" : styles.isDemo].filter(Boolean).join(" ");
const cell = (it: Item, k: number, half: number) => {
const chg = (it.price - it.base) / it.base * 100;
return (
/* 방향은 색만으로 전하지 않는다 — 화살표와 부호를 늘 같이 적는다 */
<span className={styles.item} key={half + "-" + k} data-i={k}>
<span className={styles.sym}>{it.sym}</span>
<span className={styles.price}>{money(it.price)}</span>
<span className={`${styles.chg} ${chg >= 0 ? styles.upText : styles.downText}`}>
{arrow(chg)} {pct(chg)}
</span>
</span>
);
};
return (
<div
className={rootCls}
onPointerMove={() => setLive(true)}
onPointerDown={() => setLive(true)}
onFocus={() => setLive(true)}
>
<div className={styles.panel}>
<div className={styles.head}>
{/* 아이콘은 Phosphor Duotone SVG 를 그대로 인라인했다 — 색은 currentColor 로 물려받는다 */}
<span className={styles.pulse} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="m96 40l33.52 88H56Zm104 88h-70.48L160 208Z" opacity=".2" />
<path d="M240 128a8 8 0 0 1-8 8h-27.06l-37.78 75.58A8 8 0 0 1 160 216h-.4a8 8 0 0 1-7.08-5.14L95.35 60.76l-32.07 70.55A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.85l37.87-83.31a8 8 0 0 1 14.76.46l57.51 151l31.85-63.71A8 8 0 0 1 200 120h32a8 8 0 0 1 8 8" />
</g>
</svg>
</span>
<p className={styles.title}>실시간 시세</p>
<span className={styles.hint}>올려 두면 멈춤</span>
</div>
{/* 테이프 창(overflow:hidden)과 흐르는 트랙을 분리한다 — transform 은 언제나 트랙에만 건다 */}
<div className={styles.tape} tabIndex={0} aria-label="시세 테이프">
<div
className={styles.track}
style={live ? { animationDuration: durationS.toFixed(2) + "s" } : undefined}
onAnimationIteration={onIteration}
>
<div className={styles.half} ref={halfRef} data-half="0">
{items.map((it, k) => cell(it, k, 0))}
</div>
<div className={styles.half} data-half="1" aria-hidden="true">
{items.map((it, k) => cell(it, k, 1))}
</div>
</div>
</div>
</div>
</div>
);
}02 가로로 흐르는 티커 테이프 — 측정 누적면적 2.469% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 주식 앱 ui 실시간 시세판 9종 — API 키 없이
이 데모가 속한 글
주식 앱 ui 실시간 시세판 9종 — API 키 없이주식 앱 ui 의 시세판은 사람이 새로고침하지 않아도 숫자가 저절로 바뀌는 화면입니다. 값이 오르면 초록으로, 내리면 빨강으로 한 번 번쩍인 뒤 잠잠해지는 그 장면을 아홉 가지로 만들었고, 증권사 계정도 유료 데이터 이 글의 데모 전부 보기


