05Order book whose bid and ask depth breathes
Cumulative size becomes a scaleX bar mirrored left and right, and each update lets transition carry the length
Best forThe order book beside a trading screen, or an internal liquidity check tool
scaleXtransform-origintransition

<main class="stage">
<div class="ob is-demo" id="ob">
<div class="ob__board">
<div class="ob__head">
<p class="ob__title">Order book</p>
<span class="ob__sym">ORBIT</span>
</div>
<div class="ob__legend"><span>Price</span><span>Size</span><span>Total</span></div>
<!-- 위쪽은 파는 쪽. 가운데에서 멀어질수록 쌓인 양이 커진다 -->
<ul class="ob__side" id="obAsk" data-ask="Ask level {n}">
<li class="ob__row" data-lv="4"><button type="button" class="ob__cell ob__cell--ask"><span class="ob__bar ob__bar--ask" data-bar></span><span class="ob__price">18,480</span><span class="ob__qty" data-qty>0</span><span class="ob__cum" data-cum>0</span></button></li>
<li class="ob__row" data-lv="3"><button type="button" class="ob__cell ob__cell--ask"><span class="ob__bar ob__bar--ask" data-bar></span><span class="ob__price">18,470</span><span class="ob__qty" data-qty>0</span><span class="ob__cum" data-cum>0</span></button></li>
<li class="ob__row" data-lv="2"><button type="button" class="ob__cell ob__cell--ask"><span class="ob__bar ob__bar--ask" data-bar></span><span class="ob__price">18,462</span><span class="ob__qty" data-qty>0</span><span class="ob__cum" data-cum>0</span></button></li>
<li class="ob__row" data-lv="1"><button type="button" class="ob__cell ob__cell--ask"><span class="ob__bar ob__bar--ask" data-bar></span><span class="ob__price">18,455</span><span class="ob__qty" data-qty>0</span><span class="ob__cum" data-cum>0</span></button></li>
</ul>
<div class="ob__mid">
<!-- 두 장을 같은 칸에 겹쳐 두고 steps(1, end) 로 컷 교체한다 — 반투명 유령 프레임이 안 생긴다 -->
<span class="ob__nowWrap">
<span class="ob__now ob__now--a is-up" data-now>▲ 18,450</span>
<span class="ob__now ob__now--b is-down">▼ 18,447</span>
</span>
<span class="ob__spread"><span class="ob__spreadK">Spread</span><span class="ob__spreadV" data-spread>0</span></span>
</div>
<!-- 아래쪽은 사는 쪽 -->
<ul class="ob__side" id="obBid" data-bid="Bid level {n}">
<li class="ob__row" data-lv="1"><button type="button" class="ob__cell ob__cell--bid"><span class="ob__bar ob__bar--bid" data-bar></span><span class="ob__price">18,445</span><span class="ob__qty" data-qty>0</span><span class="ob__cum" data-cum>0</span></button></li>
<li class="ob__row" data-lv="2"><button type="button" class="ob__cell ob__cell--bid"><span class="ob__bar ob__bar--bid" data-bar></span><span class="ob__price">18,438</span><span class="ob__qty" data-qty>0</span><span class="ob__cum" data-cum>0</span></button></li>
<li class="ob__row" data-lv="3"><button type="button" class="ob__cell ob__cell--bid"><span class="ob__bar ob__bar--bid" data-bar></span><span class="ob__price">18,430</span><span class="ob__qty" data-qty>0</span><span class="ob__cum" data-cum>0</span></button></li>
<li class="ob__row" data-lv="4"><button type="button" class="ob__cell ob__cell--bid"><span class="ob__bar ob__bar--bid" data-bar></span><span class="ob__price">18,421</span><span class="ob__qty" data-qty>0</span><span class="ob__cum" data-cum>0</span></button></li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 매수·매도 깊이가 늘었다 줄었다 하는 판 — 가운데 체결가를 두고 위는 파는 쪽, 아래는 사는 쪽.
// 줄마다 그 줄까지 쌓인 양을 막대로 그리고, 새 값이 들어올 때마다 막대 길이가 달라진다.
//
// 실사용에서는 시드 PRNG 가 400ms 마다 줄마다의 남은 수량을 다시 굴리고 JS 가 scaleX 만 갈아 끼운다.
// 미리보기(.is-demo)는 같은 흐름을 2초 CSS 루프에 눕힌다 — JS 는 그동안 값을 안 건드린다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $tick-up;
@include stage($stage-ink);
.ob { display: flex; justify-content: center; width: 100%; }
.ob__board {
width: min(100%, 340px); background: $tick-panel; border-radius: $r-card;
box-shadow: $shadow-raised; padding: $sp-3;
}
.ob__head { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; padding-bottom: $sp-2; }
.ob__title { margin: 0; font-weight: 800; font-size: clamp(11px, 3vw, 13px); line-height: 1.5; color: $subject-cream; }
.ob__sym { font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.5; color: $tick-mute; letter-spacing: .04em; }
// 칸은 repeat(N, 1fr) 로 짠다 — flex: 1 1 0 칸 위에 scaleX 막대를 얹으면 BD19(탭 인디케이터) 오탐을 부르고,
// 칸 폭도 내용에 따라 흔들린다
.ob__legend, .ob__cell, .ob__mid { display: grid; grid-template-columns: repeat(3, 1fr); align-items: center; }
.ob__legend { padding: 0 $sp-1; }
.ob__legend span { font-weight: 700; font-size: clamp(8px, 2vw, 9px); line-height: 1.6; color: $tick-mute; }
.ob__legend span:nth-child(2) { text-align: center; }
.ob__legend span:nth-child(3) { text-align: right; }
.ob__side { list-style: none; margin: 0; padding: 0; }
.ob__row { position: relative; }
// 버튼은 폭이 fit-content 라 1fr 칸이 0 으로 접힌다 — width: 100% 를 반드시 적는다 (281-04 실측)
.ob__cell {
position: relative; width: 100%; padding: 0 $sp-1; margin: 0; border: 0;
background: transparent; cursor: pointer; text-align: left;
}
.ob__cell:focus-visible { outline: 2px solid $tick-mute; outline-offset: -2px; }
// 그 줄까지 쌓인 양 막대. 절대 배치라 글자 칸의 자리를 안 먹고, 글자는 position: relative 로 막대 위에 올린다.
// 갱신마다 transition 이 길이를 이어 준다 — 값이 튀지 않고 흘러간다
.ob__bar { position: absolute; top: 1px; bottom: 1px; left: 0; right: 0; transition: transform $dur-quick $ease-out; }
// 파는 쪽은 오른쪽 끝에서 왼쪽으로, 사는 쪽은 왼쪽 끝에서 오른쪽으로 뻗는다 — transform-origin 이 서로 반대다.
// 가운데를 기준으로 늘리면 막대의 두 끝이 같이 움직여 "어느 쪽에서 쌓이는가" 가 사라진다.
// 끝 모서리도 서로 반대로 둥글려 색을 못 읽어도 방향이 보이게 했다
.ob__bar--ask { transform-origin: 100% 50%; background: rgba(255, 122, 94, .3); border-radius: $r-xs 0 0 $r-xs; }
.ob__bar--bid { transform-origin: 0 50%; background: rgba(61, 220, 151, .3); border-radius: 0 $r-xs $r-xs 0; }
.ob__price, .ob__qty, .ob__cum {
position: relative; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.6;
font-variant-numeric: tabular-nums;
}
.ob__cell--ask .ob__price { color: $tick-down; }
.ob__cell--bid .ob__price { color: $color; }
.ob__qty { color: $subject-cream; text-align: center; }
// 오른쪽 끝 숫자가 "그 줄까지 쌓인 양" 이다 — 막대 길이가 무엇을 뜻하는지 숫자로도 읽힌다
.ob__cum { color: $tick-mute; text-align: right; }
.ob__mid {
padding: 0 $sp-1;
border-top: 1px solid rgba(154, 147, 166, .35);
border-bottom: 1px solid rgba(154, 147, 166, .35);
}
.ob__nowWrap { display: grid; grid-column: span 2; }
.ob__now {
grid-area: 1 / 1; font-weight: 800; font-size: clamp(11px, 3vw, 13px); line-height: 1.6;
font-variant-numeric: tabular-nums; color: $subject-cream;
}
.ob__now.is-up { color: $color; }
.ob__now.is-down { color: $tick-down; }
.ob__spread { display: flex; justify-content: flex-end; align-items: baseline; gap: $sp-1; }
.ob__spreadK { font-weight: 700; font-size: clamp(8px, 2vw, 9px); line-height: 1.6; color: $tick-mute; }
.ob__spreadV {
font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.6;
color: $subject-cream; font-variant-numeric: tabular-nums;
}
// 실사용(정적 경로)에서는 지금 값 한 장만 보인다 — 겹쳐 둔 뒤 장은 자리만 지키고 안 보인다
.ob:not(.is-demo) .ob__now--b { opacity: 0; }
// ── 미리보기 루프(2초)
// 막대 넷은 체결가에서 멀수록 길다(쌓인 양이니까). 줄마다 위상을 달리해 2초 안에 세 번씩 길이가 달라지고,
// 복귀는 92~94% 안에 끝난다 — 24프레임의 마지막 장이 2초의 95.8% 지점이라 그 뒤는 샘플이 없다.
// 체결가 숫자는 48% 에 한 번 컷되고 94% 에 되돌아온다. 루프 안 컷은 두 번 = 초당 1회로
// WCAG 2.3.1 의 초당 3회 미만 아래다.
.ob.is-demo .ob__bar {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ob.is-demo .ob__row[data-lv="1"] .ob__bar { animation-name: obBar1; }
.ob.is-demo .ob__row[data-lv="2"] .ob__bar { animation-name: obBar2; }
.ob.is-demo .ob__row[data-lv="3"] .ob__bar { animation-name: obBar3; }
.ob.is-demo .ob__row[data-lv="4"] .ob__bar { animation-name: obBar4; }
@keyframes obBar1 {
0%, 10% { transform: scaleX(.26); }
22%, 46% { transform: scaleX(.35); }
58%, 82% { transform: scaleX(.19); }
92%, 100% { transform: scaleX(.26); }
}
@keyframes obBar2 {
0%, 4% { transform: scaleX(.48); }
16%, 40% { transform: scaleX(.39); }
52%, 76% { transform: scaleX(.57); }
88%, 100% { transform: scaleX(.48); }
}
@keyframes obBar3 {
0%, 16% { transform: scaleX(.66); }
28%, 50% { transform: scaleX(.79); }
62%, 82% { transform: scaleX(.59); }
94%, 100% { transform: scaleX(.66); }
}
@keyframes obBar4 {
0%, 8% { transform: scaleX(.92); }
20%, 44% { transform: scaleX(.83); }
56%, 78% { transform: scaleX(1); }
90%, 100% { transform: scaleX(.92); }
}
.ob.is-demo .ob__now {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ob.is-demo .ob__now--a { animation-name: obNowA; }
.ob.is-demo .ob__now--b { animation-name: obNowB; }
@keyframes obNowA { 0%, 46% { opacity: 1; } 48%, 92% { opacity: 0; } 94%, 100% { opacity: 1; } }
@keyframes obNowB { 0%, 46% { opacity: 0; } 48%, 92% { opacity: 1; } 94%, 100% { opacity: 0; } }
@media (max-width: 340px) {
// 320px 무대의 세로 여유는 174px 뿐이다 — 간격을 올리지 않고 내리고, 가장 바깥 줄 하나씩을 접는다
.ob__board { padding: $sp-1; }
.ob__head { padding-bottom: $sp-1; }
.ob__legend { padding: 0; }
.ob__cell { padding: 0; }
.ob__mid { padding: 0; }
.ob__row[data-lv="4"] { display: none; }
}
@media (prefers-reduced-motion: reduce) {
// 막대가 흐르는 연출과 숫자 컷을 멈추고 지금 값만 남긴다 — 길이는 그대로 갱신된다
.ob__bar { transition: none !important; }
.ob.is-demo .ob__bar { animation: none !important; }
.ob.is-demo .ob__now--a { animation: none !important; opacity: 1 !important; }
.ob.is-demo .ob__now--b { animation: none !important; opacity: 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);
}
.ob {
display: flex;
justify-content: center;
width: 100%;
}
.ob__board {
width: min(100%, 340px);
background: #231f28;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px;
}
.ob__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding-bottom: 8px;
}
.ob__title {
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3vw, 13px);
line-height: 1.5;
color: #fff7e6;
}
.ob__sym {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.5;
color: #9a93a6;
letter-spacing: 0.04em;
}
.ob__legend, .ob__cell, .ob__mid {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: center;
}
.ob__legend {
padding: 0 4px;
}
.ob__legend span {
font-weight: 700;
font-size: clamp(8px, 2vw, 9px);
line-height: 1.6;
color: #9a93a6;
}
.ob__legend span:nth-child(2) {
text-align: center;
}
.ob__legend span:nth-child(3) {
text-align: right;
}
.ob__side {
list-style: none;
margin: 0;
padding: 0;
}
.ob__row {
position: relative;
}
.ob__cell {
position: relative;
width: 100%;
padding: 0 4px;
margin: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.ob__cell:focus-visible {
outline: 2px solid #9a93a6;
outline-offset: -2px;
}
.ob__bar {
position: absolute;
top: 1px;
bottom: 1px;
left: 0;
right: 0;
transition: transform 220ms ease-out;
}
.ob__bar--ask {
transform-origin: 100% 50%;
background: rgba(255, 122, 94, 0.3);
border-radius: 4px 0 0 4px;
}
.ob__bar--bid {
transform-origin: 0 50%;
background: rgba(61, 220, 151, 0.3);
border-radius: 0 4px 4px 0;
}
.ob__price, .ob__qty, .ob__cum {
position: relative;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.6;
font-variant-numeric: tabular-nums;
}
.ob__cell--ask .ob__price {
color: #ff7a5e;
}
.ob__cell--bid .ob__price {
color: #3ddc97;
}
.ob__qty {
color: #fff7e6;
text-align: center;
}
.ob__cum {
color: #9a93a6;
text-align: right;
}
.ob__mid {
padding: 0 4px;
border-top: 1px solid rgba(154, 147, 166, 0.35);
border-bottom: 1px solid rgba(154, 147, 166, 0.35);
}
.ob__nowWrap {
display: grid;
grid-column: span 2;
}
.ob__now {
grid-area: 1/1;
font-weight: 800;
font-size: clamp(11px, 3vw, 13px);
line-height: 1.6;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.ob__now.is-up {
color: #3ddc97;
}
.ob__now.is-down {
color: #ff7a5e;
}
.ob__spread {
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 4px;
}
.ob__spreadK {
font-weight: 700;
font-size: clamp(8px, 2vw, 9px);
line-height: 1.6;
color: #9a93a6;
}
.ob__spreadV {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.6;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.ob:not(.is-demo) .ob__now--b {
opacity: 0;
}
.ob.is-demo .ob__bar {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.ob.is-demo .ob__row[data-lv="1"] .ob__bar {
animation-name: obBar1;
}
.ob.is-demo .ob__row[data-lv="2"] .ob__bar {
animation-name: obBar2;
}
.ob.is-demo .ob__row[data-lv="3"] .ob__bar {
animation-name: obBar3;
}
.ob.is-demo .ob__row[data-lv="4"] .ob__bar {
animation-name: obBar4;
}
@keyframes obBar1 {
0%, 10% {
transform: scaleX(0.26);
}
22%, 46% {
transform: scaleX(0.35);
}
58%, 82% {
transform: scaleX(0.19);
}
92%, 100% {
transform: scaleX(0.26);
}
}
@keyframes obBar2 {
0%, 4% {
transform: scaleX(0.48);
}
16%, 40% {
transform: scaleX(0.39);
}
52%, 76% {
transform: scaleX(0.57);
}
88%, 100% {
transform: scaleX(0.48);
}
}
@keyframes obBar3 {
0%, 16% {
transform: scaleX(0.66);
}
28%, 50% {
transform: scaleX(0.79);
}
62%, 82% {
transform: scaleX(0.59);
}
94%, 100% {
transform: scaleX(0.66);
}
}
@keyframes obBar4 {
0%, 8% {
transform: scaleX(0.92);
}
20%, 44% {
transform: scaleX(0.83);
}
56%, 78% {
transform: scaleX(1);
}
90%, 100% {
transform: scaleX(0.92);
}
}
.ob.is-demo .ob__now {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ob.is-demo .ob__now--a {
animation-name: obNowA;
}
.ob.is-demo .ob__now--b {
animation-name: obNowB;
}
@keyframes obNowA {
0%, 46% {
opacity: 1;
}
48%, 92% {
opacity: 0;
}
94%, 100% {
opacity: 1;
}
}
@keyframes obNowB {
0%, 46% {
opacity: 0;
}
48%, 92% {
opacity: 1;
}
94%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ob__board {
padding: 4px;
}
.ob__head {
padding-bottom: 4px;
}
.ob__legend {
padding: 0;
}
.ob__cell {
padding: 0;
}
.ob__mid {
padding: 0;
}
.ob__row[data-lv="4"] {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ob__bar {
transition: none !important;
}
.ob.is-demo .ob__bar {
animation: none !important;
}
.ob.is-demo .ob__now--a {
animation: none !important;
opacity: 1 !important;
}
.ob.is-demo .ob__now--b {
animation: none !important;
opacity: 0 !important;
}
}// 가짜 시세는 시드 PRNG 로 만든다 — 같은 시드면 누가 열어도 같은 흐름이 재현되고 외부 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 STEP_MS = 400; // 갱신 간격 — 2초에 5회. 막대는 transition 이 이어 주고 숫자만 갈린다
var BEST_BID = 18445;
var BEST_ASK = 18455;
var root = document.getElementById('ob');
var askList = document.getElementById('obAsk');
var bidList = document.getElementById('obBid');
// 가운데에서 바깥으로 세는 순서로 뒤집는다 — 쌓인 양은 체결가에 가까운 줄부터 더해 간다
var askCells = [].slice.call(askList.querySelectorAll('.ob__cell')).reverse();
var bidCells = [].slice.call(bidList.querySelectorAll('.ob__cell'));
var nowEl = root.querySelector('[data-now]');
function money(n) { return n.toLocaleString('en-US'); }
// 한 쪽을 굴려 줄마다 남은 수량과 거기까지 쌓인 양을 만든다
function roll(cells) {
var sum = 0, out = [];
cells.forEach(function (el) {
var q = 120 + Math.round(rand() * 880);
sum += q;
out.push({ el: el, q: q, sum: sum });
});
return out;
}
function repaint() {
var rows = roll(askCells).concat(roll(bidCells));
var max = 0;
rows.forEach(function (r) { if (r.sum > max) { max = r.sum; } });
rows.forEach(function (r) {
r.el.querySelector('[data-qty]').textContent = money(r.q);
r.el.querySelector('[data-cum]').textContent = money(r.sum);
// 막대는 width 가 아니라 scaleX 로 늘린다 — 레이아웃을 다시 계산하지 않으니 줄이 흔들리지 않고,
// 길이 변화는 transition 이 이어 준다
r.el.querySelector('[data-bar]').style.transform = 'scaleX(' + (0.16 + 0.84 * r.sum / max).toFixed(3) + ')';
});
}
var price = 18450, before = 18448;
function paintPrice() {
var up = price >= before;
// 방향을 색만으로 전하지 않는다 — 화살표를 숫자 앞에 같이 적는다
nowEl.textContent = (up ? '▲' : '▼') + ' ' + money(price);
nowEl.className = 'ob__now ob__now--a ' + (up ? 'is-up' : 'is-down');
}
var askTpl = askList.getAttribute('data-ask');
var bidTpl = bidList.getAttribute('data-bid');
askCells.forEach(function (el, i) { el.setAttribute('aria-label', askTpl.replace('{n}', i + 1)); });
bidCells.forEach(function (el, i) { el.setAttribute('aria-label', bidTpl.replace('{n}', i + 1)); });
root.querySelector('[data-spread]').textContent = money(BEST_ASK - BEST_BID);
repaint();
paintPrice();
// 격자 미리보기(.is-demo) 동안에는 CSS 키프레임만 돌고 JS 는 값을 안 건드린다 — 렌더 24프레임이 같은
// 순간을 재현해야 측정이 안 흔들린다. 포인터를 올리거나 키보드로 들어오면 그때부터 시드 시세가 갱신된다.
function live() { if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); } }
['pointerdown', 'pointermove', 'focusin'].forEach(function (ev) { root.addEventListener(ev, live); });
var timer = setInterval(function () {
if (root.classList.contains('is-demo')) { return; }
if (document.visibilityState === 'hidden') { return; }
before = price;
price = BEST_BID + Math.round(rand() * (BEST_ASK - BEST_BID));
repaint();
paintPrice();
}, STEP_MS);
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') { paintPrice(); }
});
window.addEventListener('pagehide', function () { clearInterval(timer); });// OrderBookDepth — vanilla 판(run/296/demo/05-ticker-orderbook-depth/index.html) 의 React 포트.
// 가운데 체결가를 두고 위는 파는 쪽, 아래는 사는 쪽. 줄마다 그 줄까지 쌓인 양을 막대로 그리고,
// 새 값이 들어올 때마다 막대 길이가 달라진다. 막대는 width 가 아니라 scaleX 라 레이아웃을 다시
// 계산하지 않고, 길이 변화는 transition 이 이어 준다.
import { useEffect, useRef, useState } from "react";
import styles from "./OrderBookDepth.module.scss";
export type OrderBookDepthProps = {
/** 격자 미리보기용 2초 자동 루프. 포인터를 올리거나 키보드로 들어오면 꺼지고 실제 갱신이 시작된다 */
demo?: boolean;
/** 시드 — 같은 값이면 누가 열어도 같은 흐름이 재현된다 */
seed?: number;
/** 갱신 간격(ms) */
stepMs?: number;
};
type Box<T> = { current: T };
/** 한 줄 = 그 값의 남은 수량 q 와 체결가 쪽부터 거기까지 쌓인 양 sum */
type Row = { price: number; lv: number; q: number; sum: number };
type Book = { ask: Row[]; bid: Row[]; price: number; before: number };
// 가운데에서 바깥으로 세는 순서 — 쌓인 양은 체결가에 가까운 줄부터 더해 간다
const ASK_PRICES = [18455, 18462, 18470, 18480];
const BID_PRICES = [18445, 18438, 18430, 18421];
const BEST_ASK = 18455;
const BEST_BID = 18445;
const ASK_LABEL = "매도 {n}호가";
const BID_LABEL = "매수 {n}호가";
/** 시드 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;
};
}
function roll(prices: number[], rand: () => number): Row[] {
let sum = 0;
return prices.map((price, i) => {
const q = 120 + Math.round(rand() * 880);
sum += q;
return { price, lv: i + 1, q, sum };
});
}
const money = (n: number) => n.toLocaleString("en-US");
export default function OrderBookDepth({ demo = true, seed = 296, stepMs = 400 }: OrderBookDepthProps) {
const randRef: Box<(() => number) | null> = useRef(null);
if (randRef.current === null) { randRef.current = mulberry32(seed); }
const rand = randRef.current as () => number;
const [book, setBook] = useState<Book>(() => ({
ask: roll(ASK_PRICES, rand), bid: roll(BID_PRICES, rand), price: 18450, before: 18448,
}));
const [live, setLive] = useState(!demo);
useEffect(() => {
if (!live) { return; }
const id = window.setInterval(() => {
// 탭이 안 보이는 동안은 세지 않는다 — 돌아왔을 때 밀린 갱신이 한꺼번에 터지지 않게
if (document.visibilityState === "hidden") { return; }
setBook((old) => ({
ask: roll(ASK_PRICES, rand),
bid: roll(BID_PRICES, rand),
before: old.price,
price: BEST_BID + Math.round(rand() * (BEST_ASK - BEST_BID)),
}));
}, stepMs);
return () => window.clearInterval(id);
}, [live, stepMs, rand]);
let max = 0;
book.ask.concat(book.bid).forEach((r) => { if (r.sum > max) { max = r.sum; } });
const scale = (sum: number) => (0.16 + 0.84 * sum / max).toFixed(3);
const up = book.price >= book.before;
const rootCls = [styles.root, live ? "" : styles.isDemo].filter(Boolean).join(" ");
const line = (r: Row, side: "ask" | "bid") => (
<li key={r.price} className={styles.row} data-lv={r.lv}>
<button
type="button"
className={`${styles.cell} ${side === "ask" ? styles.cellAsk : styles.cellBid}`}
aria-label={(side === "ask" ? ASK_LABEL : BID_LABEL).replace("{n}", String(r.lv))}
>
<span
className={`${styles.bar} ${side === "ask" ? styles.barAsk : styles.barBid}`}
style={{ transform: "scaleX(" + scale(r.sum) + ")" }}
/>
<span className={`${styles.price} ${side === "ask" ? styles.priceAsk : styles.priceBid}`}>{money(r.price)}</span>
<span className={styles.qty}>{money(r.q)}</span>
{/* 오른쪽 끝 숫자가 "그 줄까지 쌓인 양" 이다 — 막대 길이가 무엇을 뜻하는지 숫자로도 읽힌다 */}
<span className={styles.cum}>{money(r.sum)}</span>
</button>
</li>
);
return (
<div
className={rootCls}
onPointerMove={() => setLive(true)}
onPointerDown={() => setLive(true)}
onFocus={() => setLive(true)}
>
<div className={styles.board}>
<div className={styles.head}>
<p className={styles.title}>호가창</p>
<span className={styles.sym}>ORBIT</span>
</div>
<div className={styles.legend}><span>호가</span><span>잔량</span><span>누적</span></div>
{/* 위쪽은 파는 쪽. 가운데에서 멀어질수록 쌓인 양이 커진다 */}
<ul className={styles.side}>{book.ask.slice().reverse().map((r) => line(r, "ask"))}</ul>
<div className={styles.mid}>
{/* 두 장을 같은 칸에 겹쳐 두고 steps(1, end) 로 컷 교체한다 — 반투명 유령 프레임이 안 생긴다 */}
<span className={styles.nowWrap}>
<span className={`${styles.now} ${styles.nowA} ${up ? styles.nowUp : styles.nowDown}`}>
{up ? "▲" : "▼"} {money(book.price)}
</span>
<span className={`${styles.now} ${styles.nowB} ${styles.nowDown}`}>▼ 18,447</span>
</span>
<span className={styles.spread}>
<span className={styles.spreadK}>스프레드</span>
<span className={styles.spreadV}>{money(BEST_ASK - BEST_BID)}</span>
</span>
</div>
{/* 아래쪽은 사는 쪽 */}
<ul className={styles.side}>{book.bid.map((r) => line(r, "bid"))}</ul>
</div>
</div>
);
}05 Order book whose bid and ask depth breathes — Measured area 5.564% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · Stock Ticker UI: 9 Live Price Widgets, No API Key
The post this demo lives in
Stock Ticker UI: 9 Live Price Widgets, No API KeyA stock ticker ui is the strip of numbers in a finance app that keeps changing while you watch it. These nine pieces inv See all demos in this post

