03Hourly strip whose current-hour cell lights up
Hour cells run left to right into the night; the cell closest to now gets a lit dot and steps slightly forward, and scrolling settles cell by cell.
Best forHourly forecasts, school-run timing guides
scroll-snap-typearia-currentscroll-snap-align

<main class="stage stage--orange">
<section class="hs is-demo" id="hs" aria-label="Hourly forecast strip">
<header class="hs__head">
<p class="hs__title">Hourly forecast</p>
<p class="hs__hint">Swipe sideways</p>
</header>
<div class="hs__viewport" tabindex="0" aria-label="Hourly temperature list">
<ul class="hs__track">
<li class="hs__cell" data-hour="14"><span class="hs__dot" aria-hidden="true"></span><span class="hs__h">Now</span><svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true" class="hs__i"><path d="M184,128a56,56,0,1,1-56-56A56,56,0,0,1,184,128Z" opacity="0.2"/><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></svg><span class="hs__t">24°</span></li>
<li class="hs__cell" data-hour="15"><span class="hs__dot" aria-hidden="true"></span><span class="hs__h">3 PM</span><svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true" class="hs__i"><path d="M184,128a56,56,0,1,1-56-56A56,56,0,0,1,184,128Z" opacity="0.2"/><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></svg><span class="hs__t">25°</span></li>
<li class="hs__cell" data-hour="16"><span class="hs__dot" aria-hidden="true"></span><span class="hs__h">4 PM</span><svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true" class="hs__i hs__i--cloud"><path d="M139.84,84.41v0a68.22,68.22,0,0,0-41.65,46v-.11a44.08,44.08,0,0,0-38.54,5h0a48,48,0,1,1,80.19-50.94Z" opacity="0.2"/><path d="M164,72a76.2,76.2,0,0,0-20.26,2.73,55.63,55.63,0,0,0-9.41-11.54l9.51-13.57a8,8,0,1,0-13.11-9.18L121.22,54A55.9,55.9,0,0,0,96,48c-.58,0-1.16,0-1.74,0L91.37,31.71a8,8,0,1,0-15.75,2.77L78.5,50.82A56.1,56.1,0,0,0,55.23,65.67L41.61,56.14a8,8,0,1,0-9.17,13.11L46,78.77A55.55,55.55,0,0,0,40,104c0,.57,0,1.15,0,1.72L23.71,108.6a8,8,0,0,0,1.38,15.88,8.24,8.24,0,0,0,1.39-.12l16.32-2.88a55.74,55.74,0,0,0,5.86,12.42A52,52,0,0,0,84,224h80a76,76,0,0,0,0-152ZM56,104a40,40,0,0,1,72.54-23.24,76.26,76.26,0,0,0-35.62,40,52.14,52.14,0,0,0-31,4.17A40,40,0,0,1,56,104ZM164,208H84a36,36,0,1,1,4.78-71.69c-.37,2.37-.63,4.79-.77,7.23a8,8,0,0,0,16,.92,58.91,58.91,0,0,1,1.88-11.81c0-.16.09-.32.12-.48A60.06,60.06,0,1,1,164,208Z"/></svg><span class="hs__t">24°</span></li>
<li class="hs__cell" data-hour="17"><span class="hs__dot" aria-hidden="true"></span><span class="hs__h">5 PM</span><svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true" class="hs__i hs__i--cloud"><path d="M139.84,84.41v0a68.22,68.22,0,0,0-41.65,46v-.11a44.08,44.08,0,0,0-38.54,5h0a48,48,0,1,1,80.19-50.94Z" opacity="0.2"/><path d="M164,72a76.2,76.2,0,0,0-20.26,2.73,55.63,55.63,0,0,0-9.41-11.54l9.51-13.57a8,8,0,1,0-13.11-9.18L121.22,54A55.9,55.9,0,0,0,96,48c-.58,0-1.16,0-1.74,0L91.37,31.71a8,8,0,1,0-15.75,2.77L78.5,50.82A56.1,56.1,0,0,0,55.23,65.67L41.61,56.14a8,8,0,1,0-9.17,13.11L46,78.77A55.55,55.55,0,0,0,40,104c0,.57,0,1.15,0,1.72L23.71,108.6a8,8,0,0,0,1.38,15.88,8.24,8.24,0,0,0,1.39-.12l16.32-2.88a55.74,55.74,0,0,0,5.86,12.42A52,52,0,0,0,84,224h80a76,76,0,0,0,0-152ZM56,104a40,40,0,0,1,72.54-23.24,76.26,76.26,0,0,0-35.62,40,52.14,52.14,0,0,0-31,4.17A40,40,0,0,1,56,104ZM164,208H84a36,36,0,1,1,4.78-71.69c-.37,2.37-.63,4.79-.77,7.23a8,8,0,0,0,16,.92,58.91,58.91,0,0,1,1.88-11.81c0-.16.09-.32.12-.48A60.06,60.06,0,1,1,164,208Z"/></svg><span class="hs__t">23°</span></li>
<li class="hs__cell" data-hour="18"><span class="hs__dot" aria-hidden="true"></span><span class="hs__h">6 PM</span><svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true" class="hs__i hs__i--cloud"><path d="M240,128a80,80,0,0,1-80,80H72A56,56,0,1,1,85.92,97.74l0,.1A80,80,0,0,1,240,128Z" opacity="0.2"/><path d="M160,40A88.09,88.09,0,0,0,81.29,88.67,64,64,0,1,0,72,216h88a88,88,0,0,0,0-176Zm0,160H72a48,48,0,0,1,0-96c1.1,0,2.2,0,3.29.11A88,88,0,0,0,72,128a8,8,0,0,0,16,0,72,72,0,1,1,72,72Z"/></svg><span class="hs__t">21°</span></li>
<li class="hs__cell" data-hour="19"><span class="hs__dot" aria-hidden="true"></span><span class="hs__h">7 PM</span><svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true" class="hs__i hs__i--cloud"><path d="M240,128a80,80,0,0,1-80,80H72A56,56,0,1,1,85.92,97.74l0,.1A80,80,0,0,1,240,128Z" opacity="0.2"/><path d="M160,40A88.09,88.09,0,0,0,81.29,88.67,64,64,0,1,0,72,216h88a88,88,0,0,0,0-176Zm0,160H72a48,48,0,0,1,0-96c1.1,0,2.2,0,3.29.11A88,88,0,0,0,72,128a8,8,0,0,0,16,0,72,72,0,1,1,72,72Z"/></svg><span class="hs__t">19°</span></li>
<li class="hs__cell" data-hour="20"><span class="hs__dot" aria-hidden="true"></span><span class="hs__h">8 PM</span><svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true" class="hs__i hs__i--rain"><path d="M224,92a68,68,0,0,1-68,68H76A44,44,0,1,1,90.2,74.34v.11A68.06,68.06,0,0,1,224,92Z" opacity="0.2"/><path d="M158.66,196.44l-32,48a8,8,0,1,1-13.32-8.88l32-48a8,8,0,0,1,13.32,8.88ZM232,92a76.08,76.08,0,0,1-76,76H132.28l-29.62,44.44a8,8,0,1,1-13.32-8.88L113.05,168H76A52,52,0,1,1,76,64a53.26,53.26,0,0,1,8.92.76A76.08,76.08,0,0,1,232,92Zm-16,0A60.06,60.06,0,0,0,96,88.46a8,8,0,0,1-16-.92q.21-3.66.77-7.23A38.11,38.11,0,0,0,76,80a36,36,0,0,0,0,72h80A60.07,60.07,0,0,0,216,92Z"/></svg><span class="hs__t">18°</span></li>
<li class="hs__cell" data-hour="21"><span class="hs__dot" aria-hidden="true"></span><span class="hs__h">9 PM</span><svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true" class="hs__i hs__i--rain"><path d="M224,92a68,68,0,0,1-68,68H76A44,44,0,1,1,90.2,74.34v.11A68.06,68.06,0,0,1,224,92Z" opacity="0.2"/><path d="M158.66,196.44l-32,48a8,8,0,1,1-13.32-8.88l32-48a8,8,0,0,1,13.32,8.88ZM232,92a76.08,76.08,0,0,1-76,76H132.28l-29.62,44.44a8,8,0,1,1-13.32-8.88L113.05,168H76A52,52,0,1,1,76,64a53.26,53.26,0,0,1,8.92.76A76.08,76.08,0,0,1,232,92Zm-16,0A60.06,60.06,0,0,0,96,88.46a8,8,0,0,1-16-.92q.21-3.66.77-7.23A38.11,38.11,0,0,0,76,80a36,36,0,0,0,0,72h80A60.07,60.07,0,0,0,216,92Z"/></svg><span class="hs__t">17°</span></li>
<li class="hs__cell" data-hour="22"><span class="hs__dot" aria-hidden="true"></span><span class="hs__h">10 PM</span><svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true" class="hs__i hs__i--rain"><path d="M224,92a68,68,0,0,1-68,68H76A44,44,0,1,1,90.2,74.34v.11A68.06,68.06,0,0,1,224,92Z" opacity="0.2"/><path d="M158.66,196.44l-32,48a8,8,0,1,1-13.32-8.88l32-48a8,8,0,0,1,13.32,8.88ZM232,92a76.08,76.08,0,0,1-76,76H132.28l-29.62,44.44a8,8,0,1,1-13.32-8.88L113.05,168H76A52,52,0,1,1,76,64a53.26,53.26,0,0,1,8.92.76A76.08,76.08,0,0,1,232,92Zm-16,0A60.06,60.06,0,0,0,96,88.46a8,8,0,0,1-16-.92q.21-3.66.77-7.23A38.11,38.11,0,0,0,76,80a36,36,0,0,0,0,72h80A60.07,60.07,0,0,0,216,92Z"/></svg><span class="hs__t">17°</span></li>
</ul>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 시간별 띠 — 스크롤 상자(viewport)와 칸 줄(track)을 분리한다(051 실측).
// .is-demo 동안에는 스냅을 끈다: mandatory 스냅이 트랙 transform 을 다시 붙잡아 움직임을 먹는다(262 실측).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.hs {
box-sizing: border-box;
width: min(300px, 92vw);
padding: $sp-2;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-raised;
}
.hs * { box-sizing: border-box; }
.hs__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
}
.hs__title {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.hs__hint {
margin: 0;
font-size: 10px;
font-weight: 500;
line-height: 1.3;
color: rgba($subject-ink, .72);
}
.hs__viewport {
margin-top: $sp-2;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
&:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; border-radius: $r-control; }
}
.hs__track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 52px;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
.hs__cell {
position: relative;
display: grid;
justify-items: center;
gap: $sp-1;
padding: $sp-2 0 $sp-1;
border-radius: $r-control;
background: rgba(23, 20, 26, .06);
scroll-snap-align: start;
font-variant-numeric: tabular-nums;
}
.hs__h {
font-size: 10px;
font-weight: 700;
line-height: 1.3;
// 셀 면이 주황 무대 위 얇은 오버레이라 합성 배경이 #c49387 다 — 알파 .72 로는 4.0 에 그친다
color: $subject-ink;
}
.hs__i { color: #ba9f72; }
.hs__i--cloud { color: #5b6478; }
.hs__i--rain { color: $subject-blue; }
.hs__t {
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.hs__dot {
position: absolute;
top: $sp-1;
right: $sp-1;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(23, 20, 26, 0);
}
// 지금 칸 — 점이 켜지고 칸이 살짝 앞으로 나온다
.hs__cell.is-now {
background: rgba(110, 200, 255, .35);
// 실제 조작 경로에서도 지금 칸은 2px 앞으로 — 미리보기 루프(hs-pop)가 이 값을 이어받는다
transform: translateY(-2px);
.hs__dot { background: $subject-blue; }
.hs__h { color: $subject-ink; }
}
// ── 격자 미리보기 루프 (2초) ──
// 트랙이 한 칸 미끄러졌다 돌아오고(85% 안), 그 사이 지금 칸의 불은 컷으로 다음 칸으로 넘어간다.
@keyframes hs-slide {
0%, 30% { transform: translateX(0); }
44%, 80% { transform: translateX(-52px); }
90%, 100% { transform: translateX(0); }
}
@keyframes hs-lita { 0%, 43% { background-color: rgba(110, 200, 255, .35); } 44%, 87% { background-color: rgba(23, 20, 26, .06); } 88%, 100% { background-color: rgba(110, 200, 255, .35); } }
@keyframes hs-litb { 0%, 43% { background-color: rgba(23, 20, 26, .06); } 44%, 87% { background-color: rgba(110, 200, 255, .35); } 88%, 100% { background-color: rgba(23, 20, 26, .06); } }
@keyframes hs-dota { 0%, 43% { opacity: 1; } 44%, 87% { opacity: 0; } 88%, 100% { opacity: 1; } }
@keyframes hs-dotb { 0%, 43% { opacity: 0; } 44%, 87% { opacity: 1; } 88%, 100% { opacity: 0; } }
@keyframes hs-pop {
0%, 41% { transform: translateY(0); }
44% { transform: translateY(-2px); }
58%, 85% { transform: translateY(-2px); }
88% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes hs-dotpulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.6); }
}
.hs.is-demo {
.hs__viewport { scroll-snap-type: none; }
.hs__track {
animation-name: hs-slide;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
// 렌더러가 시각에 따라 어느 칸이든 지금 칸으로 골랐을 수 있으니 첫 두 칸을 순서대로 켠다
.hs__cell:nth-child(1) { animation-name: hs-lita, hs-pop; }
.hs__cell:nth-child(2) { animation-name: hs-litb; }
.hs__cell:nth-child(1), .hs__cell:nth-child(2) {
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.hs__cell:nth-child(1) .hs__dot { animation-name: hs-dota, hs-dotpulse; animation-duration: $duration, 1s; animation-timing-function: steps(1, end), $ease-out; animation-iteration-count: infinite, infinite; animation-fill-mode: backwards, backwards; }
.hs__cell:nth-child(2) .hs__dot { animation-name: hs-dotb; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; animation-fill-mode: backwards; }
}
@media (max-width: 340px) {
.hs { padding: $sp-2 $sp-1; }
.hs__track { grid-auto-columns: 48px; }
}
@media (prefers-reduced-motion: reduce) {
.hs.is-demo .hs__track,
.hs.is-demo .hs__cell,
.hs.is-demo .hs__dot {
animation: none !important;
transform: none !important;
}
// 미리보기 루프 규칙과 같은 특이도로 되돌린다 — 정지 상태에서도 지금 칸은 색·점·2px 앞으로가 남는다
.hs__cell.is-now,
.hs.is-demo .hs__cell.is-now { background: rgba(110, 200, 255, .35) !important; transform: translateY(-2px) !important; }
.hs__cell.is-now .hs__dot { opacity: 1 !important; }
}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);
}
.hs {
box-sizing: border-box;
width: min(300px, 92vw);
padding: 8px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.hs * {
box-sizing: border-box;
}
.hs__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.hs__title {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.hs__hint {
margin: 0;
font-size: 10px;
font-weight: 500;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.hs__viewport {
margin-top: 8px;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.hs__viewport::-webkit-scrollbar {
display: none;
}
.hs__viewport:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 12px;
}
.hs__track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 52px;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.hs__cell {
position: relative;
display: grid;
justify-items: center;
gap: 4px;
padding: 8px 0 4px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.06);
scroll-snap-align: start;
font-variant-numeric: tabular-nums;
}
.hs__h {
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: #17141a;
}
.hs__i {
color: #ba9f72;
}
.hs__i--cloud {
color: #5b6478;
}
.hs__i--rain {
color: #2f6df6;
}
.hs__t {
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.hs__dot {
position: absolute;
top: 4px;
right: 4px;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(23, 20, 26, 0);
}
.hs__cell.is-now {
background: rgba(110, 200, 255, 0.35);
transform: translateY(-2px);
}
.hs__cell.is-now .hs__dot {
background: #2f6df6;
}
.hs__cell.is-now .hs__h {
color: #17141a;
}
@keyframes hs-slide {
0%, 30% {
transform: translateX(0);
}
44%, 80% {
transform: translateX(-52px);
}
90%, 100% {
transform: translateX(0);
}
}
@keyframes hs-lita {
0%, 43% {
background-color: rgba(110, 200, 255, 0.35);
}
44%, 87% {
background-color: rgba(23, 20, 26, 0.06);
}
88%, 100% {
background-color: rgba(110, 200, 255, 0.35);
}
}
@keyframes hs-litb {
0%, 43% {
background-color: rgba(23, 20, 26, 0.06);
}
44%, 87% {
background-color: rgba(110, 200, 255, 0.35);
}
88%, 100% {
background-color: rgba(23, 20, 26, 0.06);
}
}
@keyframes hs-dota {
0%, 43% {
opacity: 1;
}
44%, 87% {
opacity: 0;
}
88%, 100% {
opacity: 1;
}
}
@keyframes hs-dotb {
0%, 43% {
opacity: 0;
}
44%, 87% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@keyframes hs-pop {
0%, 41% {
transform: translateY(0);
}
44% {
transform: translateY(-2px);
}
58%, 85% {
transform: translateY(-2px);
}
88% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes hs-dotpulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.6);
}
}
.hs.is-demo .hs__viewport {
scroll-snap-type: none;
}
.hs.is-demo .hs__track {
animation-name: hs-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hs.is-demo .hs__cell:nth-child(1) {
animation-name: hs-lita, hs-pop;
}
.hs.is-demo .hs__cell:nth-child(2) {
animation-name: hs-litb;
}
.hs.is-demo .hs__cell:nth-child(1), .hs.is-demo .hs__cell:nth-child(2) {
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.hs.is-demo .hs__cell:nth-child(1) .hs__dot {
animation-name: hs-dota, hs-dotpulse;
animation-duration: 2s, 1s;
animation-timing-function: steps(1, end), ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.hs.is-demo .hs__cell:nth-child(2) .hs__dot {
animation-name: hs-dotb;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@media (max-width: 340px) {
.hs {
padding: 8px 4px;
}
.hs__track {
grid-auto-columns: 48px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs.is-demo .hs__track,
.hs.is-demo .hs__cell,
.hs.is-demo .hs__dot {
animation: none !important;
transform: none !important;
}
.hs__cell.is-now,
.hs.is-demo .hs__cell.is-now {
background: rgba(110, 200, 255, 0.35) !important;
transform: translateY(-2px) !important;
}
.hs__cell.is-now .hs__dot {
opacity: 1 !important;
}
}// 실제 시각과 가장 가까운 칸을 찾아 점을 켜고 aria-current 를 붙인다.
var hsRoot = document.getElementById('hs');
var hsCells = hsRoot.querySelectorAll('.hs__cell');
var hsNow = new Date().getHours();
var hsBest = 0;
var hsGap = 99;
for (var i = 0; i < hsCells.length; i++) {
var gap = Math.abs(Number(hsCells[i].getAttribute('data-hour')) - hsNow);
if (gap < hsGap) { hsGap = gap; hsBest = i; }
}
var hsTarget = hsCells[hsBest];
hsTarget.classList.add('is-now');
hsTarget.setAttribute('aria-current', 'true');
// 칸을 누르면 미리보기 루프를 그만두고 진짜 스크롤로 쓰게 둔다
hsRoot.addEventListener('pointerdown', function () {
hsRoot.classList.remove('is-demo');
}, { once: true });import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./WeatherHourlyStrip.module.scss";
// vanilla 판(run/281/demo/03-weather-hourly-strip/index.html) 의 React 포트.
// 실제 시각과 가장 가까운 칸을 찾아 점을 켜고 aria-current 를 붙인다.
// 칸에 손을 대면 미리보기 루프를 그만두고 진짜 스크롤로 쓰게 둔다.
export type HsHour = {
hour: number;
/** 칸 위 라벨("지금", "15시" …) — 언어팩이 통째로 바꾼다 */
label: string;
icon: "sun" | "cloudsun" | "cloud" | "rain";
/** 온도 글자 그대로("24°") */
temp: string;
};
export type WeatherHourlyStripProps = {
title?: string;
hint?: string;
groupLabel?: string;
viewportLabel?: string;
hours?: HsHour[];
/** 격자 미리보기 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_HOURS: HsHour[] = [
{ hour: 14, label: "지금", icon: "sun", temp: "24°" },
{ hour: 15, label: "15시", icon: "sun", temp: "25°" },
{ hour: 16, label: "16시", icon: "cloudsun", temp: "24°" },
{ hour: 17, label: "17시", icon: "cloudsun", temp: "23°" },
{ hour: 18, label: "18시", icon: "cloud", temp: "21°" },
{ hour: 19, label: "19시", icon: "cloud", temp: "19°" },
{ hour: 20, label: "20시", icon: "rain", temp: "18°" },
{ hour: 21, label: "21시", icon: "rain", temp: "17°" },
{ hour: 22, label: "22시", icon: "rain", temp: "17°" },
];
function SunIcon() {
return (
<svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M184,128a56,56,0,1,1-56-56A56,56,0,0,1,184,128Z" opacity="0.2"/><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></svg>
);
}
function CloudSunIcon() {
return (
<svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M139.84,84.41v0a68.22,68.22,0,0,0-41.65,46v-.11a44.08,44.08,0,0,0-38.54,5h0a48,48,0,1,1,80.19-50.94Z" opacity="0.2"/><path d="M164,72a76.2,76.2,0,0,0-20.26,2.73,55.63,55.63,0,0,0-9.41-11.54l9.51-13.57a8,8,0,1,0-13.11-9.18L121.22,54A55.9,55.9,0,0,0,96,48c-.58,0-1.16,0-1.74,0L91.37,31.71a8,8,0,1,0-15.75,2.77L78.5,50.82A56.1,56.1,0,0,0,55.23,65.67L41.61,56.14a8,8,0,1,0-9.17,13.11L46,78.77A55.55,55.55,0,0,0,40,104c0,.57,0,1.15,0,1.72L23.71,108.6a8,8,0,0,0,1.38,15.88,8.24,8.24,0,0,0,1.39-.12l16.32-2.88a55.74,55.74,0,0,0,5.86,12.42A52,52,0,0,0,84,224h80a76,76,0,0,0,0-152ZM56,104a40,40,0,0,1,72.54-23.24,76.26,76.26,0,0,0-35.62,40,52.14,52.14,0,0,0-31,4.17A40,40,0,0,1,56,104ZM164,208H84a36,36,0,1,1,4.78-71.69c-.37,2.37-.63,4.79-.77,7.23a8,8,0,0,0,16,.92,58.91,58.91,0,0,1,1.88-11.81c0-.16.09-.32.12-.48A60.06,60.06,0,1,1,164,208Z"/></svg>
);
}
function CloudIcon() {
return (
<svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M240,128a80,80,0,0,1-80,80H72A56,56,0,1,1,85.92,97.74l0,.1A80,80,0,0,1,240,128Z" opacity="0.2"/><path d="M160,40A88.09,88.09,0,0,0,81.29,88.67,64,64,0,1,0,72,216h88a88,88,0,0,0,0-176Zm0,160H72a48,48,0,0,1,0-96c1.1,0,2.2,0,3.29.11A88,88,0,0,0,72,128a8,8,0,0,0,16,0,72,72,0,1,1,72,72Z"/></svg>
);
}
function RainIcon() {
return (
<svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M224,92a68,68,0,0,1-68,68H76A44,44,0,1,1,90.2,74.34v.11A68.06,68.06,0,0,1,224,92Z" opacity="0.2"/><path d="M158.66,196.44l-32,48a8,8,0,1,1-13.32-8.88l32-48a8,8,0,0,1,13.32,8.88ZM232,92a76.08,76.08,0,0,1-76,76H132.28l-29.62,44.44a8,8,0,1,1-13.32-8.88L113.05,168H76A52,52,0,1,1,76,64a53.26,53.26,0,0,1,8.92.76A76.08,76.08,0,0,1,232,92Zm-16,0A60.06,60.06,0,0,0,96,88.46a8,8,0,0,1-16-.92q.21-3.66.77-7.23A38.11,38.11,0,0,0,76,80a36,36,0,0,0,0,72h80A60.07,60.07,0,0,0,216,92Z"/></svg>
);
}
function HourIcon({ kind }: { kind: HsHour["icon"] }) {
if (kind === "cloudsun") return <CloudSunIcon />;
if (kind === "cloud") return <CloudIcon />;
if (kind === "rain") return <RainIcon />;
return <SunIcon />;
}
export default function WeatherHourlyStrip({
title = "시간별 예보",
hint = "옆으로 밀어 보세요",
groupLabel = "시간별 예보 띠",
viewportLabel = "시간별 온도 목록",
hours = DEFAULT_HOURS,
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: WeatherHourlyStripProps) {
const [live, setLive] = useState(demo);
// 실제 시각과 가장 가까운 칸 — 마운트 때 한 번 정한다
const [nowIdx] = useState(() => {
const nowHour = new Date().getHours();
let best = 0;
let gap = 99;
hours.forEach((h: HsHour, i: number) => {
const g = Math.abs(h.hour - nowHour);
if (g < gap) { gap = g; best = i; }
});
return best;
});
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.hs, live ? styles.isDemo : ""].filter(Boolean).join(" ");
const iconCls = (kind: HsHour["icon"]): string => {
if (kind === "cloud") return `${styles.i} ${styles["i--cloud"]}`;
if (kind === "rain") return `${styles.i} ${styles["i--rain"]}`;
return styles.i;
};
return (
<main className={styles.stage}>
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.head}>
<p className={styles.title}>{title}</p>
<p className={styles.hint}>{hint}</p>
</header>
<div
className={styles.viewport}
tabIndex={0}
aria-label={viewportLabel}
onPointerDown={() => setLive(false)}
>
<ul className={styles.track}>
{hours.map((h: HsHour, i: number) => (
<li
key={h.hour}
className={[styles.cell, i === nowIdx ? styles.isNow : ""].filter(Boolean).join(" ")}
aria-current={i === nowIdx ? "true" : undefined}
>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.h}>{h.label}</span>
<span className={iconCls(h.icon)} aria-hidden="true"><HourIcon kind={h.icon} /></span>
<span className={styles.t}>{h.temp}</span>
</li>
))}
</ul>
</div>
</section>
</main>
);
}03 Hourly strip whose current-hour cell lights up — Measured area 10.719% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · Weather Widget Design — 9 Widgets, No Library
The post this demo lives in
Weather Widget Design — 9 Widgets, No LibraryWeather widget design is the sky card on a dashboard — temperature, hour strip, week bars. These nine are plain HTML, SC See all demos in this post
If you like this effect - similar demos
Embeds06 Status badge timeline that lights up stage by stage9 Activity Feed UI Widgets — Sidebar-Ready Lists
Embeds06 Instagram post embed card — a loading gradient whose angle keeps being recomputed9 YouTube Embed Code UI Patterns (Copy-Paste)
Embeds01 Thumbnail Rail That Syncs to the Current Page by Calculating Scroll RatioPDF Viewer Implementation: 9 UI Patterns