AI Landing Page Design Kit: 9 Glowing Orb Sections
AI landing page design has gone AI-native without painting a gradient background. The page stays one flat dark color, and a single ball of light — an orb —
An orb is nothing more than a circle filled with stacked radial gradients: a white highlight near its upper left, a colored body, and a wider ring of light sitting behind it. There is no photograph and no filter: blur anywhere in this kit, which is why a page full of them still weighs almost nothing. Scroll the one-pager below and count how many things are actually lit at any one moment.

That page comes apart into nine pieces, and the grid below keeps them in scroll order rather than ranked by how impressive they look: the first screen, the nav that sits above it, four blocks of body content, the sign-up band, the footer, and last, the controls that turn up between all of them. Popularity would have put the CTA band first, but nobody meets a CTA band first. The same nine slots filled with dots and squiggles are the Memphis pattern landing kit, and carrying this grammar into an inbox is what marketing email design does.
01 hero-orb
02 nav-orb
03 card-grid-orb
04 feature-list-orb
05 pricing-orb
06 testimonial-orb
07 cta-band-orb
08 footer-orb
09 buttons-inputs-orb
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Rising-orb hero
- 02 Gliding orb-dot nav
- 03 Glimmer-response card grid
- 04 Waking feature list
- 05 Orb-badge pricing
- 06 Orb-avatar testimonials
- 07 Drifting-orb CTA band
- 08 Orbit-beacon footer
- 09 Orb buttons & chat input
Every section of this ai home page design was rendered at 480×300 and measured frame by frame, so the light budget is a number rather than an impression. The cumulative moved area across all nine adds up to 81.469 percent of one frame, and the drifting CTA band alone accounts for 33.242 of that — a little over 40 percent of the entire kit's motion in a single section.
| Section | Page behind it | Moved area | Avg intensity | Frames with motion |
|---|---|---|---|---|
| 01 Rising-orb hero | ink #17141a | 9.735% | 43.7 | 23/23 |
| 02 Gliding orb-dot nav | ink | 2.107% | 48.7 | 16/23 |
| 03 Glimmer-response card grid | ink | 10.769% | 49.8 | 18/23 |
| 04 Waking feature list | ink | 4.368% | 40.5 | 23/23 |
| 05 Orb-badge pricing | white #ffffff | 0.681% | 36.2 | 16/23 |
| 06 Orb-avatar testimonials | ink | 10.181% | 40.9 | 17/23 |
| 07 Drifting-orb CTA band | orange #ff4d1f | 33.242% | 25.5 | 18/23 |
| 08 Orbit-beacon footer | white | 6.163% | 70.5 | 23/23 |
| 09 Orb buttons & chat input | ink | 4.223% | 40.3 | 12/23 |
Six of the nine sit directly on the ink stage. Pricing is the only component that puts orbs on white; the CTA band and the footer keep their own ink block and simply change the color of the page around it, which is how you check that the style survives a bright section.
01Rising-orb hero
What this ai landing page ui design shows
A 104px sphere floats up and down inside a 152px box while the soft ring of light behind it swells on the same two-second breath. Four gradients are stacked to build it: a white highlight, a sky-blue cheek, a lilac reflection on the far side, and a deep blue body that sinks almost to the stage color at its rim.

<main class="stage">
<section class="ho">
<div class="ho__text">
<p class="ho__eyebrow">
<span class="ho__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg></span>
New model is live
</p>
<h1 class="ho__title">Ask once, get the first draft</h1>
<p class="ho__sub">Meeting notes, decks and email — sorted in one pass.</p>
<a class="ho__cta" href="#" role="button">Start for free<span class="ho__arrow" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,128l-72,72V56Z" opacity="0.2"/><path d="M221.66,122.34l-72-72A8,8,0,0,0,136,56v64H40a8,8,0,0,0,0,16h96v64a8,8,0,0,0,13.66,5.66l72-72A8,8,0,0,0,221.66,122.34ZM152,180.69V75.31L204.69,128Z"/></svg></span></a>
</div>
<div class="ho__orbwrap" aria-hidden="true">
<span class="ho__halo"></span>
<span class="ho__orb"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개 — 변형은 이 셋만 바꾼다
$duration: $dur-loop; // 떠오름·맥박 한 주기 (설정 `초` 와 같은 2s)
$easing: ease-in-out; // 위아래 왕복은 양끝에서 멎어야 숨쉬듯 보인다
$color: $subject-sky; // 오브의 빛 색
// 이 편 아홉 항목은 전부 잉크 무대다 — 빛나는 오브는 어두운 바탕에서만 빛으로 읽힌다
@include stage($stage-ink);
.ho {
display: grid; grid-template-columns: 1fr auto; align-items: center; gap: $sp-5;
width: min(100%, 432px);
}
// 대비 실측(WCAG 상대휘도로 계산): 무대 $stage-ink 의 상대휘도 0.00757
.ho__eyebrow {
display: flex; align-items: center; gap: $sp-1; margin: 0 0 $sp-2;
font-weight: 700; font-size: 10px; line-height: 1; letter-spacing: .04em;
color: $color; // $subject-sky on $stage-ink = 9.87:1
}
.ho__ico { display: inline-flex; width: 12px; height: 12px; flex: 0 0 auto; }
.ho__ico svg { width: 100%; height: 100%; }
.ho__title {
margin: 0; font-weight: 800; font-size: clamp(17px, 5.2vw, 25px); line-height: 1.18;
letter-spacing: -.01em; color: $subject-cream; // $subject-cream on $stage-ink = 17.11:1
}
// keep-all 은 한국어에만 — 어절 가운데서 끊기지 않게 한다(묻기만 하면 / 초안이 나옵니다).
// 일본어에 걸면 띄어쓰기가 없어 줄바꿈 자리가 사라지고 가로로 넘친다 (ja 사본 480×300 실측 2026-09-12)
.ho__title:lang(ko) {
word-break: keep-all;
}
.ho__sub {
margin: $sp-2 0 0; font-weight: 500; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.45;
color: rgba(255, 247, 230, .72); // 잉크 위에서 #beb7ad 로 합성 = 9.18:1
}
.ho__cta {
display: inline-flex; align-items: center; gap: $sp-1; margin-top: $sp-4;
padding: $sp-2 $sp-4; border-radius: $r-pill;
background: $subject-cream; color: $subject-ink; // 크림 위 잉크 글자 = 17.11:1
font-weight: 700; font-size: 11px; line-height: 1; text-decoration: none;
}
.ho__arrow { display: inline-flex; width: 12px; height: 12px; }
.ho__arrow svg { width: 100%; height: 100%; }
// ── 오브: 겹으로 쌓은 radial-gradient 네 겹 = 하이라이트 · 빛 · 반사 · 코어
.ho__orbwrap {
position: relative; width: 152px; height: 152px; flex: 0 0 auto;
display: grid; place-items: center;
}
.ho__orb {
position: relative; z-index: 1; width: 104px; height: 104px; border-radius: 50%;
background:
radial-gradient(circle at 34% 28%, rgba(255, 255, 255, .92), rgba(255, 255, 255, 0) 44%),
radial-gradient(circle at 28% 66%, rgba(110, 200, 255, .9), rgba(110, 200, 255, 0) 58%),
radial-gradient(circle at 74% 72%, rgba(185, 160, 255, .8), rgba(185, 160, 255, 0) 56%),
radial-gradient(circle at 50% 50%, rgba(47, 109, 246, .95) 10%, rgba(23, 20, 26, .96) 92%);
animation-name: hoFloat; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 빛 무리 — 오브와 같은 2초 주기로 커졌다 작아진다. 자기 중심에서 커진다 (blog-demo 규칙 8)
.ho__halo {
position: absolute; inset: 0; border-radius: 50%; transform-origin: 50% 50%;
background: radial-gradient(circle at 50% 50%, rgba(110, 200, 255, .42), rgba(110, 200, 255, .16) 48%, rgba(110, 200, 255, 0) 72%);
animation-name: hoPulse; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// translateY 왕복 — 0%·100% 가 같은 값이라 루프 이음매가 없다
@keyframes hoFloat {
0%, 100% { transform: translateY(7px); }
50% { transform: translateY(-7px); }
}
// scale 최대가 1 — 커져도 감싼 상자(152px) 밖으로 안 나간다. 넘침(BD9) 방지
@keyframes hoPulse {
0%, 100% { transform: scale(.82); opacity: .5; }
50% { transform: scale(1); opacity: .9; }
}
// ── 320×200 폰 실측 대비 압축 (BD9)
@media (max-width: 340px), (max-height: 220px) {
.ho { gap: $sp-3; }
.ho__eyebrow { margin-bottom: $sp-1; font-size: 9px; }
.ho__title { font-size: 15px; }
.ho__sub { margin-top: $sp-1; font-size: 9px; }
.ho__cta { margin-top: $sp-2; padding: $sp-1 $sp-3; font-size: 10px; }
.ho__orbwrap { width: 96px; height: 96px; }
.ho__orb { width: 68px; height: 68px; }
}
@media (prefers-reduced-motion: reduce) {
.ho__orb, .ho__halo { animation: none !important; transform: none !important; opacity: .9 !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);
}
.ho {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 20px;
width: min(100%, 432px);
}
.ho__eyebrow {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
font-weight: 700;
font-size: 10px;
line-height: 1;
letter-spacing: 0.04em;
color: #6ec8ff;
}
.ho__ico {
display: inline-flex;
width: 12px;
height: 12px;
flex: 0 0 auto;
}
.ho__ico svg {
width: 100%;
height: 100%;
}
.ho__title {
margin: 0;
font-weight: 800;
font-size: clamp(17px, 5.2vw, 25px);
line-height: 1.18;
letter-spacing: -0.01em;
color: #fff7e6;
}
.ho__title:lang(ko) {
word-break: keep-all;
}
.ho__sub {
margin: 8px 0 0;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.45;
color: rgba(255, 247, 230, 0.72);
}
.ho__cta {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 16px;
padding: 8px 16px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1;
text-decoration: none;
}
.ho__arrow {
display: inline-flex;
width: 12px;
height: 12px;
}
.ho__arrow svg {
width: 100%;
height: 100%;
}
.ho__orbwrap {
position: relative;
width: 152px;
height: 152px;
flex: 0 0 auto;
display: grid;
place-items: center;
}
.ho__orb {
position: relative;
z-index: 1;
width: 104px;
height: 104px;
border-radius: 50%;
background: radial-gradient(circle at 34% 28%, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0) 44%), radial-gradient(circle at 28% 66%, rgba(110, 200, 255, 0.9), rgba(110, 200, 255, 0) 58%), radial-gradient(circle at 74% 72%, rgba(185, 160, 255, 0.8), rgba(185, 160, 255, 0) 56%), radial-gradient(circle at 50% 50%, rgba(47, 109, 246, 0.95) 10%, rgba(23, 20, 26, 0.96) 92%);
animation-name: hoFloat;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.ho__halo {
position: absolute;
inset: 0;
border-radius: 50%;
transform-origin: 50% 50%;
background: radial-gradient(circle at 50% 50%, rgba(110, 200, 255, 0.42), rgba(110, 200, 255, 0.16) 48%, rgba(110, 200, 255, 0) 72%);
animation-name: hoPulse;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes hoFloat {
0%, 100% {
transform: translateY(7px);
}
50% {
transform: translateY(-7px);
}
}
@keyframes hoPulse {
0%, 100% {
transform: scale(0.82);
opacity: 0.5;
}
50% {
transform: scale(1);
opacity: 0.9;
}
}
@media (max-width: 340px), (max-height: 220px) {
.ho {
gap: 12px;
}
.ho__eyebrow {
margin-bottom: 4px;
font-size: 9px;
}
.ho__title {
font-size: 15px;
}
.ho__sub {
margin-top: 4px;
font-size: 9px;
}
.ho__cta {
margin-top: 8px;
padding: 4px 12px;
font-size: 10px;
}
.ho__orbwrap {
width: 96px;
height: 96px;
}
.ho__orb {
width: 68px;
height: 68px;
}
}
@media (prefers-reduced-motion: reduce) {
.ho__orb, .ho__halo {
animation: none !important;
transform: none !important;
opacity: 0.9 !important;
}
}import styles from "./HeroOrb.module.scss";
export type HeroOrbProps = { duration?: string; easing?: string; color?: string };
export default function HeroOrb({ duration = "2s", easing = "ease-in-out", color = "#6ec8ff" }: HeroOrbProps) {
// 변수 3개는 props 로 — 오브가 한 번 떠올랐다 내려앉는 시간 · 그 이징 · 빛 색
const motion = { animationDuration: duration, animationTimingFunction: easing };
return (
<main className={styles.stage}>
<section className={styles.ho}>
<div className={styles.text}>
<p className={styles.eyebrow} style={{ color }}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg>
</span>
새 모델 공개
</p>
<h1 className={styles.title}>묻기만 하면 초안이 나옵니다</h1>
<p className={styles.sub}>회의록·기획서·메일을 한 번에 정리합니다.</p>
<a className={styles.cta} href="#" role="button" onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); }}>
무료로 시작하기
<span className={styles.arrow} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,128l-72,72V56Z" opacity="0.2"/><path d="M221.66,122.34l-72-72A8,8,0,0,0,136,56v64H40a8,8,0,0,0,0,16h96v64a8,8,0,0,0,13.66,5.66l72-72A8,8,0,0,0,221.66,122.34ZM152,180.69V75.31L204.69,128Z"/></svg>
</span>
</a>
</div>
<div className={styles.orbwrap} aria-hidden="true">
<span className={styles.halo} style={motion} />
<span className={styles.orb} style={motion} />
</div>
</section>
</main>
);
}01 · Rising-orb hero — Measured area 9.735% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Both loops end on the value they started with, so the hero never snaps back.
.ho__orb {
background:
radial-gradient(circle at 34% 28%, rgba(255, 255, 255, .92), rgba(255, 255, 255, 0) 44%),
radial-gradient(circle at 28% 66%, rgba(110, 200, 255, .9), rgba(110, 200, 255, 0) 58%),
radial-gradient(circle at 74% 72%, rgba(185, 160, 255, .8), rgba(185, 160, 255, 0) 56%),
radial-gradient(circle at 50% 50%, rgba(47, 109, 246, .95) 10%, rgba(23, 20, 26, .96) 92%);
animation-name: hoFloat; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes hoFloat {
0%, 100% { transform: translateY(7px); }
50% { transform: translateY(-7px); }
}
@keyframes hoPulse {
0%, 100% { transform: scale(.82); opacity: .5; }
50% { transform: scale(1); opacity: .9; }
}
02Gliding orb-dot nav
What happens on screen
An 8px dot lives under the five nav links and slides across to whichever one is current. Its five stopping points ship as CSS variables that the demo refills on load from each tab's own offsetLeft and width, so the dot lands centered on the label in any language rather than near it, and the preview loop visits three of them before returning home at 86 percent of the cycle.

<main class="stage">
<div class="nv is-demo" id="nv">
<div class="nv__top">
<span class="nv__brand">
<span class="nv__mark" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg></span>
Orbit AI
</span>
<a class="nv__cta" href="#" role="button">Get started</a>
</div>
<nav class="nv__nav" aria-label="Main menu">
<ul class="nv__tabs" id="tabs">
<li class="nv__item"><a class="nv__tab is-on" href="#" aria-current="page">Home</a></li>
<li class="nv__item"><a class="nv__tab" href="#">Features</a></li>
<li class="nv__item"><a class="nv__tab" href="#">Models</a></li>
<li class="nv__item"><a class="nv__tab" href="#">Pricing</a></li>
<li class="nv__item"><a class="nv__tab" href="#">Docs</a></li>
</ul>
<span class="nv__dot" id="dot" aria-hidden="true"></span>
</nav>
<div class="nv__panel" aria-hidden="true">
<span class="nv__block"></span>
<span class="nv__line nv__line--a"></span>
<span class="nv__line nv__line--b"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개 — 변형은 이 셋만 바꾼다
$duration: $dur-base; // 실제 클릭에서 점이 옮겨가는 시간
$easing: $ease-spring; // 도착점에서 멎는 감속
$color: $subject-sky; // 발광 점의 빛 색
// 이 편 아홉 항목은 전부 잉크 무대다 — 빛나는 오브는 어두운 바탕에서만 빛으로 읽힌다
@include stage($stage-ink);
.nv {
box-sizing: border-box; width: min(100%, 432px); padding: $sp-4;
border: 1px solid rgba(255, 247, 230, .1); border-radius: $r-card;
background: rgba(110, 200, 255, .05);
}
// 대비 실측(WCAG 상대휘도 계산): 무대 $stage-ink 의 상대휘도 0.00757,
// 카드 바탕(잉크+하늘색 5%)은 0.0128 — 아래 값은 카드 바탕 기준이다
.nv__top { display: flex; align-items: center; justify-content: space-between; gap: $sp-3; }
.nv__brand {
display: inline-flex; align-items: center; gap: $sp-1;
font-weight: 800; font-size: 12px; line-height: 1; letter-spacing: -.01em;
color: $subject-cream; // $subject-cream 기준 15.7:1
}
.nv__mark { display: inline-flex; width: 14px; height: 14px; color: $color; }
.nv__mark svg { width: 100%; height: 100%; }
.nv__cta {
padding: $sp-1 $sp-3; border-radius: $r-pill; background: $subject-cream; color: $subject-ink;
font-weight: 700; font-size: 10px; line-height: 1.4; text-decoration: none; // 크림 위 잉크 = 17.11:1
}
// ── 탭 줄: 점이 고정 px 로 움직이므로 탭은 내용 폭이어야 한다 (BD19)
.nv__nav { position: relative; margin-top: $sp-4; padding-bottom: $sp-3; }
.nv__tabs { display: flex; gap: $sp-5; margin: 0; padding: 0 0 $sp-2; list-style: none; }
.nv__item { flex: 0 0 auto; }
.nv__tab {
flex: 0 0 auto; display: block; font-weight: 600; font-size: 12px; line-height: 1.2;
text-decoration: none; color: rgba(255, 247, 230, .66); // 카드 바탕 위 = 7.2:1
}
.nv__tab.is-on { font-weight: 700; color: $subject-cream; }
// 발광 점 — 활성 탭 중심에 서고, translateX 로 옆 탭으로 미끄러진다
.nv__dot {
// 다섯 자리는 짐작이 아니라 Playwright getBoundingClientRect 실측값 (480×300 렌더, 2026-09-12)
--p1: 1.5px; --p2: 38px; --p3: 80px; --p4: 122px; --p5: 164px;
position: absolute; left: 0; bottom: 0; width: 8px; height: 8px; border-radius: 50%;
background: radial-gradient(circle at 38% 32%, rgba(255, 255, 255, .95), rgba(110, 200, 255, .95) 58%, rgba(47, 109, 246, .9) 100%);
box-shadow: 0 0 12px 4px rgba(110, 200, 255, .45);
transform: translateX(var(--p1));
transition: transform $duration $easing; // 사람이 클릭했을 때의 이동
}
// 미리보기 루프: 2초 안에 1 → 3 → 5 → 1 세 자리를 돈다. 복귀는 86% 에 끝난다
// (라벨을 바꾸지 않고 점만 옮기므로 반투명 유령 프레임이 생기지 않는다)
.nv.is-demo .nv__dot {
animation-name: nvGlide; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes nvGlide {
0%, 8% { transform: translateX(var(--p1)); }
24%, 34% { transform: translateX(var(--p3)); }
50%, 62% { transform: translateX(var(--p5)); }
86%, 100% { transform: translateX(var(--p1)); }
}
// ── 탭이 가리키는 화면 (정적 장식)
.nv__panel { display: grid; gap: $sp-2; margin-top: $sp-4; }
.nv__block { height: 40px; border-radius: $r-sm; background: rgba(255, 247, 230, .09); }
.nv__line { height: 8px; border-radius: $r-pill; background: rgba(255, 247, 230, .07); }
.nv__line--a { width: 76%; }
.nv__line--b { width: 52%; }
// ── 320×200 폰 실측 대비 압축 (BD9). 좁은 화면에서는 간격을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.nv { padding: $sp-2; }
.nv__brand { font-size: 11px; }
.nv__cta { font-size: 9px; padding: 0 $sp-2; }
.nv__nav { margin-top: $sp-2; padding-bottom: $sp-2; }
.nv__tabs { gap: $sp-3; padding-bottom: $sp-1; }
.nv__tab { font-size: 11px; }
.nv__dot { --p1: 1px; --p2: 28px; --p3: 60px; --p4: 93px; --p5: 125px; } // 320×200 실측값
.nv__panel { gap: $sp-1; margin-top: $sp-2; }
.nv__block { height: 28px; }
.nv__line { height: 6px; }
}
@media (prefers-reduced-motion: reduce) {
.nv__dot { transition: none !important; }
.nv.is-demo .nv__dot { animation: none !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);
}
.nv {
box-sizing: border-box;
width: min(100%, 432px);
padding: 16px;
border: 1px solid rgba(255, 247, 230, 0.1);
border-radius: 16px;
background: rgba(110, 200, 255, 0.05);
}
.nv__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.nv__brand {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 800;
font-size: 12px;
line-height: 1;
letter-spacing: -0.01em;
color: #fff7e6;
}
.nv__mark {
display: inline-flex;
width: 14px;
height: 14px;
color: #6ec8ff;
}
.nv__mark svg {
width: 100%;
height: 100%;
}
.nv__cta {
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
text-decoration: none;
}
.nv__nav {
position: relative;
margin-top: 16px;
padding-bottom: 12px;
}
.nv__tabs {
display: flex;
gap: 20px;
margin: 0;
padding: 0 0 8px;
list-style: none;
}
.nv__item {
flex: 0 0 auto;
}
.nv__tab {
flex: 0 0 auto;
display: block;
font-weight: 600;
font-size: 12px;
line-height: 1.2;
text-decoration: none;
color: rgba(255, 247, 230, 0.66);
}
.nv__tab.is-on {
font-weight: 700;
color: #fff7e6;
}
.nv__dot {
--p1: 1.5px;
--p2: 38px;
--p3: 80px;
--p4: 122px;
--p5: 164px;
position: absolute;
left: 0;
bottom: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: radial-gradient(circle at 38% 32%, rgba(255, 255, 255, 0.95), rgba(110, 200, 255, 0.95) 58%, rgba(47, 109, 246, 0.9) 100%);
box-shadow: 0 0 12px 4px rgba(110, 200, 255, 0.45);
transform: translateX(var(--p1));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.nv.is-demo .nv__dot {
animation-name: nvGlide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes nvGlide {
0%, 8% {
transform: translateX(var(--p1));
}
24%, 34% {
transform: translateX(var(--p3));
}
50%, 62% {
transform: translateX(var(--p5));
}
86%, 100% {
transform: translateX(var(--p1));
}
}
.nv__panel {
display: grid;
gap: 8px;
margin-top: 16px;
}
.nv__block {
height: 40px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.09);
}
.nv__line {
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.07);
}
.nv__line--a {
width: 76%;
}
.nv__line--b {
width: 52%;
}
@media (max-width: 340px), (max-height: 220px) {
.nv {
padding: 8px;
}
.nv__brand {
font-size: 11px;
}
.nv__cta {
font-size: 9px;
padding: 0 8px;
}
.nv__nav {
margin-top: 8px;
padding-bottom: 8px;
}
.nv__tabs {
gap: 12px;
padding-bottom: 4px;
}
.nv__tab {
font-size: 11px;
}
.nv__dot {
--p1: 1px;
--p2: 28px;
--p3: 60px;
--p4: 93px;
--p5: 125px;
}
.nv__panel {
gap: 4px;
margin-top: 8px;
}
.nv__block {
height: 28px;
}
.nv__line {
height: 6px;
}
}
@media (prefers-reduced-motion: reduce) {
.nv__dot {
transition: none !important;
}
.nv.is-demo .nv__dot {
animation: none !important;
}
}var nav = document.getElementById('nv');
var dot = document.getElementById('dot');
var tabs = document.querySelectorAll('.nv__tab');
function dotLeft(tab) {
return tab.offsetLeft + tab.offsetWidth / 2 - dot.offsetWidth / 2;
}
function layoutDots() {
// --p1~--p5 의 기본값은 English 480×300 실측 px 이다. 라벨 폭은 언어마다 다르므로
// 실제 렌더 좌표로 한 번 덮어써 en·ja 에서도 점이 라벨 한가운데 선다
for (var k = 0; k < tabs.length; k++) {
dot.style.setProperty('--p' + (k + 1), dotLeft(tabs[k]) + 'px');
}
}
function moveDot(tab) {
// 클릭·키보드는 실제 렌더 좌표로 옮긴다 (미리보기 루프는 키프레임이 --p 값을 쓴다)
dot.style.transform = 'translateX(' + dotLeft(tab) + 'px)';
}
layoutDots();
window.addEventListener('resize', layoutDots);
for (var i = 0; i < tabs.length; i++) {
tabs[i].addEventListener('click', function (e) {
e.preventDefault();
nav.classList.remove('is-demo');
for (var j = 0; j < tabs.length; j++) {
tabs[j].classList.remove('is-on');
tabs[j].removeAttribute('aria-current');
}
this.classList.add('is-on');
this.setAttribute('aria-current', 'page');
moveDot(this);
});
}import { useEffect, useRef, useState } from "react";
import styles from "./NavOrb.module.scss";
export type NavOrbProps = { duration?: string; easing?: string; color?: string };
const TABS = ["홈", "기능", "모델", "요금", "문서"];
export default function NavOrb({ duration = "300ms", easing = "cubic-bezier(.2,.8,.2,1)", color = "#6ec8ff" }: NavOrbProps) {
// 변수 3개는 props 로 — 점이 옆 탭으로 옮겨가는 시간 · 그 이징 · 빛 색
const [demo, setDemo] = useState(true);
const [active, setActive] = useState(0);
const navRef = useRef<HTMLElement>(null);
// --p1~--p5 의 기본값은 한국어 480×300 실측 px 이다. 라벨 폭은 언어마다 다르므로
// 실제 렌더 좌표로 한 번 덮어써 en·ja 에서도 점이 라벨 한가운데 선다
useEffect(() => {
const root = navRef.current;
if (!root) return;
const dotEl = root.querySelector("[data-dot]") as HTMLElement | null;
if (!dotEl) return;
const tabEls = root.querySelectorAll("[data-tab]");
for (let i = 0; i < tabEls.length; i++) {
const t = tabEls[i] as HTMLElement;
dotEl.style.setProperty(`--p${i + 1}`, `${t.offsetLeft + t.offsetWidth / 2 - dotEl.offsetWidth / 2}px`);
}
}, []);
// 자리는 CSS 변수 --p1~--p5 (480×300·320×200 렌더 실측값). 클릭하면 그 자리로 미끄러진다
const dotStyle = demo
? { transitionDuration: duration, transitionTimingFunction: easing }
: { transform: `translateX(var(--p${active + 1}))`, transitionDuration: duration, transitionTimingFunction: easing };
return (
<main className={styles.stage}>
<div className={styles.nv}>
<div className={styles.top}>
<span className={styles.brand}>
<span className={styles.mark} aria-hidden="true" style={{ color }}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg>
</span>
Orbit AI
</span>
<a className={styles.cta} href="#" role="button" onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); }}>시작하기</a>
</div>
<nav className={styles.nav} aria-label="주요 메뉴" ref={navRef}>
<ul className={styles.tabs}>
{TABS.map((label, i) => (
<li className={styles.item} key={label}>
<a
className={`${styles.tab} ${active === i ? styles.isOn : ""}`}
href="#"
data-tab=""
aria-current={active === i ? "page" : undefined}
onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); setDemo(false); setActive(i); }}
>
{label}
</a>
</li>
))}
</ul>
<span className={`${styles.dot} ${demo ? styles.isDemo : ""}`} aria-hidden="true" data-dot="" style={dotStyle} />
</nav>
<div className={styles.panel} aria-hidden="true">
<span className={styles.block} />
<span className={`${styles.line} ${styles.lineA}`} />
<span className={`${styles.line} ${styles.lineB}`} />
</div>
</div>
</main>
);
}02 · Gliding orb-dot nav — Measured area 2.107% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
A real click moves the same dot through a 300ms transition, and the link it lands on carries aria-current="page".
.nv__dot {
--p1: 1.5px; --p2: 38px; --p3: 80px; --p4: 122px; --p5: 164px;
transform: translateX(var(--p1));
transition: transform $duration $easing;
}
@keyframes nvGlide {
0%, 8% { transform: translateX(var(--p1)); }
24%, 34% { transform: translateX(var(--p3)); }
50%, 62% { transform: translateX(var(--p5)); }
86%, 100% { transform: translateX(var(--p1)); }
}
03Glimmer-response card grid
What moves
Four cards each carry a 28px orb, and pointing at one raises the card 2px, deepens its shadow, and grows the orb to 1.35 scale from its own center. The preview walks that same state through the four cards at 120ms intervals.

<main class="stage">
<div class="cg is-demo" id="cg">
<a class="cg__card" href="#">
<span class="cg__orb" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg></span>
<span class="cg__name">Summarize docs</span>
<span class="cg__line">Long meeting notes in three lines</span>
</a>
<a class="cg__card" href="#">
<span class="cg__orb" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M96,240l16-80L48,136,160,16,144,96l64,24Z" opacity="0.2"/><path d="M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z"/></svg></span>
<span class="cg__name">Draft anything</span>
<span class="cg__line">Skeletons for email and decks</span>
</a>
<a class="cg__card" href="#">
<span class="cg__orb" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M240,124a48,48,0,0,1-32,45.27h0V176a40,40,0,0,1-80,0,40,40,0,0,1-80,0v-6.73h0a48,48,0,0,1,0-90.54V72a40,40,0,0,1,80,0,40,40,0,0,1,80,0v6.73A48,48,0,0,1,240,124Z" opacity="0.2"/><path d="M248,124a56.11,56.11,0,0,0-32-50.61V72a48,48,0,0,0-88-26.49A48,48,0,0,0,40,72v1.39a56,56,0,0,0,0,101.2V176a48,48,0,0,0,88,26.49A48,48,0,0,0,216,176v-1.41A56.09,56.09,0,0,0,248,124ZM88,208a32,32,0,0,1-31.81-28.56A55.87,55.87,0,0,0,64,180h8a8,8,0,0,0,0-16H64A40,40,0,0,1,50.67,86.27,8,8,0,0,0,56,78.73V72a32,32,0,0,1,64,0v68.26A47.8,47.8,0,0,0,88,128a8,8,0,0,0,0,16,32,32,0,0,1,0,64Zm104-44h-8a8,8,0,0,0,0,16h8a55.87,55.87,0,0,0,7.81-.56A32,32,0,1,1,168,144a8,8,0,0,0,0-16,47.8,47.8,0,0,0-32,12.26V72a32,32,0,0,1,64,0v6.73a8,8,0,0,0,5.33,7.54A40,40,0,0,1,192,164Zm16-52a8,8,0,0,1-8,8h-4a36,36,0,0,1-36-36V80a8,8,0,0,1,16,0v4a20,20,0,0,0,20,20h4A8,8,0,0,1,208,112ZM60,120H56a8,8,0,0,1,0-16h4A20,20,0,0,0,80,84V80a8,8,0,0,1,16,0v4A36,36,0,0,1,60,120Z"/></svg></span>
<span class="cg__name">Keeps context</span>
<span class="cg__line">Picks up the last thread</span>
</a>
<a class="cg__card" href="#">
<span class="cg__orb" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></span>
<span class="cg__name">Team chat</span>
<span class="cg__line">Ask straight from Slack</span>
</a>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개 — 변형은 이 셋만 바꾼다
$duration: $dur-quick; // 실제 호버에서 카드가 뜨는 시간
$easing: $ease-spring;
$color: $subject-sky; // 카드 오브의 빛 색
// 이 편 아홉 항목은 전부 잉크 무대다 — 빛나는 오브는 어두운 바탕에서만 빛으로 읽힌다
@include stage($stage-ink);
.cg {
display: grid; grid-template-columns: repeat(2, 1fr); gap: $sp-3;
width: min(100%, 432px);
}
// 대비 실측(WCAG 상대휘도 계산): 카드 바탕(잉크+하늘색 6%)의 상대휘도 0.0137
.cg__card {
box-sizing: border-box; display: grid; gap: $sp-2; padding: $sp-3;
border: 1px solid rgba(255, 247, 230, .1); border-radius: $r-card;
background: rgba(110, 200, 255, .06);
box-shadow: 0 2px 6px rgba(23, 20, 26, .28);
text-decoration: none;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.cg__name {
font-weight: 700; font-size: 13px; line-height: 1.2; color: $subject-cream; // $subject-cream 기준 15.46:1
}
.cg__line {
font-weight: 500; font-size: 10px; line-height: 1.3;
color: rgba(255, 247, 230, .72); // 카드 바탕 위에서 #beb7ad 로 합성 = 8.32:1
}
// 오브 — 겹으로 쌓은 radial-gradient. 커질 땐 자기 중심에서 (blog-demo 규칙 8)
.cg__orb {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 50%; transform-origin: 50% 50%;
color: $stage-ink;
background:
radial-gradient(circle at 34% 28%, rgba(255, 255, 255, .95), rgba(255, 255, 255, 0) 46%),
radial-gradient(circle at 66% 74%, rgba(185, 160, 255, .85), rgba(185, 160, 255, 0) 60%),
radial-gradient(circle at 50% 50%, rgba(110, 200, 255, 1) 20%, rgba(47, 109, 246, .95) 96%);
box-shadow: 0 0 10px rgba(110, 200, 255, .35);
transition: transform $duration $easing;
}
.cg__orb svg { width: 60%; height: 60%; }
// 사람이 실제로 올렸을 때 — 카드는 2px 뜨고 그림자가 깊어지고 오브가 커진다
.cg__card:hover, .cg__card:focus-visible {
transform: translateY(-2px); box-shadow: $shadow-raised;
}
.cg__card:hover .cg__orb, .cg__card:focus-visible .cg__orb { transform: scale(1.35); }
// ── 미리보기 루프: 네 장이 차례로 같은 호버 상태를 지난다
// animation 단축은 delay 를 0 으로 리셋한다 → 낱개 속성으로. 지연 구간의 번쩍임은 backwards 로 막는다
.cg.is-demo .cg__card {
animation-name: cgLift; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.cg.is-demo .cg__orb {
animation-name: cgOrb; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.cg.is-demo .cg__card:nth-child(2), .cg.is-demo .cg__card:nth-child(2) .cg__orb { animation-delay: 120ms; }
.cg.is-demo .cg__card:nth-child(3), .cg.is-demo .cg__card:nth-child(3) .cg__orb { animation-delay: 240ms; }
.cg.is-demo .cg__card:nth-child(4), .cg.is-demo .cg__card:nth-child(4) .cg__orb { animation-delay: 360ms; }
@keyframes cgLift {
0%, 8% { transform: translateY(0); box-shadow: 0 2px 6px rgba(23, 20, 26, .28); }
22%, 46% { transform: translateY(-2px); box-shadow: $shadow-raised; }
62%, 100% { transform: translateY(0); box-shadow: 0 2px 6px rgba(23, 20, 26, .28); }
}
@keyframes cgOrb {
0%, 8% { transform: scale(1); }
22%, 46% { transform: scale(1.35); }
62%, 100% { transform: scale(1); }
}
// ── 320×200 폰 실측 대비 압축 (BD9). 좁은 화면에서는 간격을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.cg { gap: $sp-2; }
.cg__card { gap: $sp-1; padding: $sp-2; }
.cg__orb { width: 22px; height: 22px; }
.cg__name { font-size: 11px; }
.cg__line { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.cg__card, .cg__orb { transition: none !important; }
.cg.is-demo .cg__card, .cg.is-demo .cg__orb { animation: none !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);
}
.cg {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
width: min(100%, 432px);
}
.cg__card {
box-sizing: border-box;
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid rgba(255, 247, 230, 0.1);
border-radius: 16px;
background: rgba(110, 200, 255, 0.06);
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.28);
text-decoration: none;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cg__name {
font-weight: 700;
font-size: 13px;
line-height: 1.2;
color: #fff7e6;
}
.cg__line {
font-weight: 500;
font-size: 10px;
line-height: 1.3;
color: rgba(255, 247, 230, 0.72);
}
.cg__orb {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
transform-origin: 50% 50%;
color: #17141a;
background: radial-gradient(circle at 34% 28%, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0) 46%), radial-gradient(circle at 66% 74%, rgba(185, 160, 255, 0.85), rgba(185, 160, 255, 0) 60%), radial-gradient(circle at 50% 50%, rgb(110, 200, 255) 20%, rgba(47, 109, 246, 0.95) 96%);
box-shadow: 0 0 10px rgba(110, 200, 255, 0.35);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cg__orb svg {
width: 60%;
height: 60%;
}
.cg__card:hover, .cg__card:focus-visible {
transform: translateY(-2px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cg__card:hover .cg__orb, .cg__card:focus-visible .cg__orb {
transform: scale(1.35);
}
.cg.is-demo .cg__card {
animation-name: cgLift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cg.is-demo .cg__orb {
animation-name: cgOrb;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cg.is-demo .cg__card:nth-child(2), .cg.is-demo .cg__card:nth-child(2) .cg__orb {
animation-delay: 120ms;
}
.cg.is-demo .cg__card:nth-child(3), .cg.is-demo .cg__card:nth-child(3) .cg__orb {
animation-delay: 240ms;
}
.cg.is-demo .cg__card:nth-child(4), .cg.is-demo .cg__card:nth-child(4) .cg__orb {
animation-delay: 360ms;
}
@keyframes cgLift {
0%, 8% {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.28);
}
22%, 46% {
transform: translateY(-2px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
62%, 100% {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.28);
}
}
@keyframes cgOrb {
0%, 8% {
transform: scale(1);
}
22%, 46% {
transform: scale(1.35);
}
62%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.cg {
gap: 8px;
}
.cg__card {
gap: 4px;
padding: 8px;
}
.cg__orb {
width: 22px;
height: 22px;
}
.cg__name {
font-size: 11px;
}
.cg__line {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.cg__card, .cg__orb {
transition: none !important;
}
.cg.is-demo .cg__card, .cg.is-demo .cg__orb {
animation: none !important;
}
}var grid = document.getElementById('cg');
function stopLoop() { grid.classList.remove('is-demo'); }
grid.addEventListener('pointerenter', stopLoop);
grid.addEventListener('focusin', stopLoop);
var cards = document.querySelectorAll('.cg__card');
for (var i = 0; i < cards.length; i++) {
cards[i].addEventListener('click', function (e) { e.preventDefault(); stopLoop(); });
}import { useState } from "react";
import styles from "./CardGridOrb.module.scss";
export type CardGridOrbProps = { duration?: string; easing?: string; color?: string };
const SPARKLE = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg>
);
const LIGHTNING = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M96,240l16-80L48,136,160,16,144,96l64,24Z" opacity="0.2"/><path d="M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z"/></svg>
);
const BRAIN = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M240,124a48,48,0,0,1-32,45.27h0V176a40,40,0,0,1-80,0,40,40,0,0,1-80,0v-6.73h0a48,48,0,0,1,0-90.54V72a40,40,0,0,1,80,0,40,40,0,0,1,80,0v6.73A48,48,0,0,1,240,124Z" opacity="0.2"/><path d="M248,124a56.11,56.11,0,0,0-32-50.61V72a48,48,0,0,0-88-26.49A48,48,0,0,0,40,72v1.39a56,56,0,0,0,0,101.2V176a48,48,0,0,0,88,26.49A48,48,0,0,0,216,176v-1.41A56.09,56.09,0,0,0,248,124ZM88,208a32,32,0,0,1-31.81-28.56A55.87,55.87,0,0,0,64,180h8a8,8,0,0,0,0-16H64A40,40,0,0,1,50.67,86.27,8,8,0,0,0,56,78.73V72a32,32,0,0,1,64,0v68.26A47.8,47.8,0,0,0,88,128a8,8,0,0,0,0,16,32,32,0,0,1,0,64Zm104-44h-8a8,8,0,0,0,0,16h8a55.87,55.87,0,0,0,7.81-.56A32,32,0,1,1,168,144a8,8,0,0,0,0-16,47.8,47.8,0,0,0-32,12.26V72a32,32,0,0,1,64,0v6.73a8,8,0,0,0,5.33,7.54A40,40,0,0,1,192,164Zm16-52a8,8,0,0,1-8,8h-4a36,36,0,0,1-36-36V80a8,8,0,0,1,16,0v4a20,20,0,0,0,20,20h4A8,8,0,0,1,208,112ZM60,120H56a8,8,0,0,1,0-16h4A20,20,0,0,0,80,84V80a8,8,0,0,1,16,0v4A36,36,0,0,1,60,120Z"/></svg>
);
const CHAT = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg>
);
const CARDS = [
{ icon: SPARKLE, name: "문서 요약", line: "긴 회의록을 세 줄로" },
{ icon: LIGHTNING, name: "초안 생성", line: "메일과 기획서의 뼈대" },
{ icon: BRAIN, name: "맥락 기억", line: "지난 대화를 이어서" },
{ icon: CHAT, name: "팀 채팅", line: "슬랙에서 바로 질문" },
];
export default function CardGridOrb({ duration = "220ms", easing = "cubic-bezier(.2,.8,.2,1)", color = "#6ec8ff" }: CardGridOrbProps) {
// 변수 3개는 props 로 — 카드가 뜨는 시간 · 그 이징 · 오브의 빛 색
const [demo, setDemo] = useState(true);
const stopLoop = () => setDemo(false);
return (
<main className={styles.stage} style={{ "--orb": color }}>
<div className={styles.cg} onPointerEnter={stopLoop} onFocus={stopLoop}>
{CARDS.map((card, i) => (
<a
key={card.name}
className={`${styles.card} ${demo ? styles.isDemo : ""}`}
href="#"
style={{ transitionDuration: duration, transitionTimingFunction: easing, animationDelay: `${i * 120}ms` }}
onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); stopLoop(); }}
>
<span className={styles.orb} aria-hidden="true" style={{ transitionDuration: duration, transitionTimingFunction: easing, animationDelay: `${i * 120}ms` }}>
{card.icon}
</span>
<span className={styles.name}>{card.name}</span>
<span className={styles.line}>{card.line}</span>
</a>
))}
</div>
</main>
);
}03 · Glimmer-response card grid — Measured area 10.769% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Nothing tracks the cursor, so the whole grid costs zero JavaScript.
.cg__card:hover, .cg__card:focus-visible {
transform: translateY(-2px); box-shadow: $shadow-raised;
}
.cg__card:hover .cg__orb, .cg__card:focus-visible .cg__orb { transform: scale(1.35); }
@keyframes cgLift {
0%, 8% { transform: translateY(0); box-shadow: 0 2px 6px rgba(23, 20, 26, .28); }
22%, 46% { transform: translateY(-2px); box-shadow: $shadow-raised; }
62%, 100% { transform: translateY(0); box-shadow: 0 2px 6px rgba(23, 20, 26, .28); }
}
@keyframes cgOrb {
0%, 8% { transform: scale(1); }
22%, 46% { transform: scale(1.35); }
62%, 100% { transform: scale(1); }
}
04Waking feature list
What you see
Six rows brighten in turn down the list, each one starting 334ms after the row above it. The bright window shuts at 16 percent of the two-second loop, which is 320ms — just under the 334ms gap, so two rows are never lit at once.

<main class="stage">
<section class="fl is-demo" id="fl">
<p class="fl__eyebrow">
<span class="fl__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg></span>
AI workspace
</p>
<h2 class="fl__title">Six features waking in turn</h2>
<ul class="fl__list">
<li class="fl__row"><span class="fl__orb" aria-hidden="true"></span><span class="fl__label">Find answers in docs</span></li>
<li class="fl__row"><span class="fl__orb" aria-hidden="true"></span><span class="fl__label">Summarize meeting notes</span></li>
<li class="fl__row"><span class="fl__orb" aria-hidden="true"></span><span class="fl__label">Tidy up table data</span></li>
<li class="fl__row"><span class="fl__orb" aria-hidden="true"></span><span class="fl__label">Rewrite the first draft</span></li>
<li class="fl__row"><span class="fl__orb" aria-hidden="true"></span><span class="fl__label">Translate in your tone</span></li>
<li class="fl__row"><span class="fl__orb" aria-hidden="true"></span><span class="fl__label">Generate code snippets</span></li>
</ul>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 셋만 바꾼다
$duration: $dur-loop; // 한 바퀴 2초. 여섯 행이 이 안에서 한 번씩 깬다 (2000 ÷ 6 = 334ms 간격)
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
// 오브 표면 — 왼쪽 위 하이라이트 한 겹 + 본체 한 겹. 가장자리는 무대 쪽으로 가라앉는다
@mixin orbSkin($hue) {
background:
radial-gradient(circle at 34% 26%, rgba(255, 255, 255, .92) 0%, rgba(255, 255, 255, 0) 44%),
radial-gradient(circle at 52% 54%, $hue 0%, rgba($hue, .45) 58%, rgba($subject-ink, .92) 100%);
}
.fl { width: min(100%, 400px); }
.fl__eyebrow {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(8px, 2.4vw, 10px); line-height: 1; letter-spacing: .08em;
color: rgba(255, 255, 255, .72); // 잉크 무대 위 9.81:1 — 계산 2026-09-12
}
.fl__ico { display: inline-flex; width: 12px; height: 12px; flex: 0 0 auto; color: $color; } // 장식 아이콘, 잉크 위 4.03:1
.fl__ico svg { width: 100%; height: 100%; }
.fl__title {
margin: $sp-1 0 0; font-weight: 800; font-size: clamp(13px, 3.6vw, 16px); line-height: 1.25;
color: $subject-cream; // 잉크 위 16.2:1
}
.fl__list { list-style: none; margin: $sp-3 0 0; padding: 0; }
.fl__row {
display: grid; grid-template-columns: 14px 1fr; align-items: center; gap: $sp-3;
padding: $sp-1 0; border-top: 1px solid rgba(255, 255, 255, .1);
}
.fl__row:first-child { border-top: 0; }
// 빛 무리는 box-shadow 한 겹. opacity 가 요소째 흐려지므로 오브가 어두워질 때 무리도 같이 죽는다
.fl__orb { width: 14px; height: 14px; border-radius: 50%; box-shadow: 0 0 10px rgba($color, .5); @include orbSkin($color); }
.fl__label {
font-weight: 600; font-size: clamp(10px, 3vw, 12px); line-height: 1.3;
color: $subject-cream; // 루프의 가장 어두운 순간(opacity .62)에도 잉크 위 7.09:1
}
// ── 미리보기 루프: 오브가 위에서 아래로 한 칸씩 깬다
// animation 단축을 쓰면 delay 가 0s 로 리셋된다 → 낱개 속성으로. 지연이 걸리므로 fill-mode: backwards 필수
.fl.is-demo .fl__orb,
.fl.is-demo .fl__label {
animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.fl.is-demo .fl__orb { animation-name: flWake; }
.fl.is-demo .fl__label { animation-name: flLabelWake; }
.fl.is-demo .fl__row:nth-child(1) .fl__orb, .fl.is-demo .fl__row:nth-child(1) .fl__label { animation-delay: 0ms; }
.fl.is-demo .fl__row:nth-child(2) .fl__orb, .fl.is-demo .fl__row:nth-child(2) .fl__label { animation-delay: 334ms; }
.fl.is-demo .fl__row:nth-child(3) .fl__orb, .fl.is-demo .fl__row:nth-child(3) .fl__label { animation-delay: 667ms; }
.fl.is-demo .fl__row:nth-child(4) .fl__orb, .fl.is-demo .fl__row:nth-child(4) .fl__label { animation-delay: 1000ms; }
.fl.is-demo .fl__row:nth-child(5) .fl__orb, .fl.is-demo .fl__row:nth-child(5) .fl__label { animation-delay: 1334ms; }
.fl.is-demo .fl__row:nth-child(6) .fl__orb, .fl.is-demo .fl__row:nth-child(6) .fl__label { animation-delay: 1667ms; }
// 밝은 구간은 16%(320ms) 로 끝난다 — 다음 행이 깨는 334ms 보다 짧아야 한 번에 하나만 밝다
@keyframes flWake {
0% { opacity: .32; transform: scale(.86); }
5% { opacity: 1; transform: scale(1.18); }
11% { opacity: 1; transform: scale(1.06); }
16% { opacity: .32; transform: scale(.86); }
100% { opacity: .32; transform: scale(.86); }
}
@keyframes flLabelWake {
0% { opacity: .62; }
5% { opacity: 1; }
11% { opacity: 1; }
16% { opacity: .62; }
100% { opacity: .62; }
}
// ── 320×200 폰 압축 (BD9). 이 안에서는 간격을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.fl__eyebrow { font-size: 8px; }
.fl__ico { width: 10px; height: 10px; }
.fl__title { font-size: 11px; }
.fl__list { margin-top: $sp-2; }
.fl__row { grid-template-columns: 10px 1fr; gap: $sp-2; }
.fl__orb { width: 10px; height: 10px; }
.fl__label { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.fl.is-demo .fl__orb,
.fl.is-demo .fl__label { animation: none !important; opacity: 1 !important; transform: none !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);
}
.fl {
width: min(100%, 400px);
}
.fl__eyebrow {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(8px, 2.4vw, 10px);
line-height: 1;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.72);
}
.fl__ico {
display: inline-flex;
width: 12px;
height: 12px;
flex: 0 0 auto;
color: #2f6df6;
}
.fl__ico svg {
width: 100%;
height: 100%;
}
.fl__title {
margin: 4px 0 0;
font-weight: 800;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.25;
color: #fff7e6;
}
.fl__list {
list-style: none;
margin: 12px 0 0;
padding: 0;
}
.fl__row {
display: grid;
grid-template-columns: 14px 1fr;
align-items: center;
gap: 12px;
padding: 4px 0;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.fl__row:first-child {
border-top: 0;
}
.fl__orb {
width: 14px;
height: 14px;
border-radius: 50%;
box-shadow: 0 0 10px rgba(47, 109, 246, 0.5);
background: radial-gradient(circle at 34% 26%, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0) 44%), radial-gradient(circle at 52% 54%, #2f6df6 0%, rgba(47, 109, 246, 0.45) 58%, rgba(23, 20, 26, 0.92) 100%);
}
.fl__label {
font-weight: 600;
font-size: clamp(10px, 3vw, 12px);
line-height: 1.3;
color: #fff7e6;
}
.fl.is-demo .fl__orb,
.fl.is-demo .fl__label {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fl.is-demo .fl__orb {
animation-name: flWake;
}
.fl.is-demo .fl__label {
animation-name: flLabelWake;
}
.fl.is-demo .fl__row:nth-child(1) .fl__orb, .fl.is-demo .fl__row:nth-child(1) .fl__label {
animation-delay: 0ms;
}
.fl.is-demo .fl__row:nth-child(2) .fl__orb, .fl.is-demo .fl__row:nth-child(2) .fl__label {
animation-delay: 334ms;
}
.fl.is-demo .fl__row:nth-child(3) .fl__orb, .fl.is-demo .fl__row:nth-child(3) .fl__label {
animation-delay: 667ms;
}
.fl.is-demo .fl__row:nth-child(4) .fl__orb, .fl.is-demo .fl__row:nth-child(4) .fl__label {
animation-delay: 1000ms;
}
.fl.is-demo .fl__row:nth-child(5) .fl__orb, .fl.is-demo .fl__row:nth-child(5) .fl__label {
animation-delay: 1334ms;
}
.fl.is-demo .fl__row:nth-child(6) .fl__orb, .fl.is-demo .fl__row:nth-child(6) .fl__label {
animation-delay: 1667ms;
}
@keyframes flWake {
0% {
opacity: 0.32;
transform: scale(0.86);
}
5% {
opacity: 1;
transform: scale(1.18);
}
11% {
opacity: 1;
transform: scale(1.06);
}
16% {
opacity: 0.32;
transform: scale(0.86);
}
100% {
opacity: 0.32;
transform: scale(0.86);
}
}
@keyframes flLabelWake {
0% {
opacity: 0.62;
}
5% {
opacity: 1;
}
11% {
opacity: 1;
}
16% {
opacity: 0.62;
}
100% {
opacity: 0.62;
}
}
@media (max-width: 340px), (max-height: 220px) {
.fl__eyebrow {
font-size: 8px;
}
.fl__ico {
width: 10px;
height: 10px;
}
.fl__title {
font-size: 11px;
}
.fl__list {
margin-top: 8px;
}
.fl__row {
grid-template-columns: 10px 1fr;
gap: 8px;
}
.fl__orb {
width: 10px;
height: 10px;
}
.fl__label {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.fl.is-demo .fl__orb,
.fl.is-demo .fl__label {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}import styles from "./FeatureListOrb.module.scss";
export type FeatureListOrbProps = {
/** 한 바퀴(ms). 행 수로 나눈 값이 행 사이 간격이 된다 */
duration?: number;
easing?: string;
/** 오브 색. 6자리 hex 로 준다 (#2f6df6) */
color?: string;
};
const FEATURES = [
"문서에서 답 찾기",
"회의록 자동 요약",
"표 데이터 정리",
"초안 다시 쓰기",
"말투 맞춰 번역",
"코드 조각 만들기",
];
// 6자리 hex 에 알파 두 자리를 붙인다 — #2f6df6 + "73" 꼴
function withAlpha(hex: string, alpha: number): string {
const n = Math.round(alpha * 255);
return hex + (n < 16 ? "0" : "") + n.toString(16);
}
export default function FeatureListOrb({
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: FeatureListOrbProps) {
const orbSkin = {
background:
"radial-gradient(circle at 34% 26%, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0) 44%), " +
"radial-gradient(circle at 52% 54%, " + color + " 0%, " + withAlpha(color, 0.45) + " 58%, rgba(23, 20, 26, 0.92) 100%)",
boxShadow: "0 0 10px " + withAlpha(color, 0.5),
};
// animation 단축을 쓰면 delay 가 0s 로 리셋된다 → 낱개로. 지연이 걸리므로 fill-mode 는 scss 쪽에서 backwards
const loop = (i: number) => ({
animationDuration: duration + "ms",
animationTimingFunction: easing,
animationDelay: Math.round((duration / FEATURES.length) * i) + "ms",
});
return (
<main className={styles.stage}>
<section className={`${styles.fl} ${styles.demoLoop}`}>
<p className={styles.eyebrow}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg>
</span>
AI 워크스페이스
</p>
<h2 className={styles.title}>차례로 깨어나는 기능 6가지</h2>
<ul className={styles.list}>
{FEATURES.map((feature, i) => (
<li className={styles.row} key={feature}>
<span className={styles.orb} aria-hidden="true" style={{ ...orbSkin, ...loop(i) }} />
<span className={styles.label} style={loop(i)}>{feature}</span>
</li>
))}
</ul>
</section>
</main>
);
}04 · Waking feature list — Measured area 4.368% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The label brightens with its orb, so the row that is awake is readable even with color stripped out.
@mixin orbSkin($hue) {
background:
radial-gradient(circle at 34% 26%, rgba(255, 255, 255, .92) 0%, rgba(255, 255, 255, 0) 44%),
radial-gradient(circle at 52% 54%, $hue 0%, rgba($hue, .45) 58%, rgba($subject-ink, .92) 100%);
}
.fl__orb { width: 14px; height: 14px; border-radius: 50%; box-shadow: 0 0 10px rgba($color, .5); @include orbSkin($color); }
.fl.is-demo .fl__row:nth-child(2) .fl__orb, .fl.is-demo .fl__row:nth-child(2) .fl__label { animation-delay: 334ms; }
@keyframes flWake {
0% { opacity: .32; transform: scale(.86); }
5% { opacity: 1; transform: scale(1.18); }
11% { opacity: 1; transform: scale(1.06); }
16% { opacity: .32; transform: scale(.86); }
100% { opacity: .32; transform: scale(.86); }
}
05Orb-badge pricing
What happens on screen
A 28px badge bobs 6px above the recommended plan once per second, and that card is the only one with a firmer border and a deeper shadow. All three plans keep the same button and the same text color, so the recommendation is carried by weight rather than by a color change.

<main class="stage">
<section class="pr is-demo" id="pr">
<header class="pr__head">
<h2 class="pr__title">Pricing</h2>
<p class="pr__sub">Pay for what you use.</p>
</header>
<div class="pr__grid">
<article class="pr__card">
<p class="pr__plan">Starter</p>
<p class="pr__price">$0<span class="pr__per">/mo</span></p>
<ul class="pr__feats">
<li class="pr__feat"><span class="pr__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg></span>20 runs/day</li>
<li class="pr__feat"><span class="pr__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg></span>1 model</li>
<li class="pr__feat"><span class="pr__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg></span>Email support</li>
</ul>
<button type="button" class="pr__pick">Choose</button>
</article>
<article class="pr__card pr__card--pick">
<span class="pr__orb" aria-hidden="true"></span>
<p class="pr__plan">Pro <span class="pr__flag">Best</span></p>
<p class="pr__price">$15<span class="pr__per">/mo</span></p>
<ul class="pr__feats">
<li class="pr__feat"><span class="pr__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg></span>500 runs/day</li>
<li class="pr__feat"><span class="pr__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg></span>4 models</li>
<li class="pr__feat"><span class="pr__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg></span>Priority queue</li>
</ul>
<button type="button" class="pr__pick">Choose</button>
</article>
<article class="pr__card">
<p class="pr__plan">Team</p>
<p class="pr__price">$39<span class="pr__per">/mo</span></p>
<ul class="pr__feats">
<li class="pr__feat"><span class="pr__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg></span>Unlimited runs</li>
<li class="pr__feat"><span class="pr__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg></span>10 seats</li>
<li class="pr__feat"><span class="pr__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg></span>Dedicated rep</li>
</ul>
<button type="button" class="pr__pick">Choose</button>
</article>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 셋만 바꾼다
$duration: $dur-loop * .5; // 1s — 2초 캡처 창의 약수라 gif 가 두 바퀴 온전히 담긴다
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.pr { width: min(100%, 432px); }
.pr__head { margin: 0; text-align: center; }
.pr__title {
margin: 0; font-weight: 800; font-size: clamp(13px, 3.6vw, 16px); line-height: 1.25;
color: $subject-ink; // 흰 무대 위 18.24:1
}
.pr__sub {
margin: $sp-1 0 0; font-weight: 500; font-size: clamp(8px, 2.4vw, 10px); line-height: 1.4;
color: rgba($subject-ink, .62); // 흰 무대 위 5.12:1
}
.pr__grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-3;
margin: $sp-3 0 0; padding-top: $sp-5; // 오브 배지가 카드 위로 나오므로 그만큼 자리를 비워 둔다
}
.pr__card {
position: relative; box-sizing: border-box; display: flex; flex-direction: column;
padding: $sp-5 $sp-3 $sp-3; border: 1px solid rgba($subject-ink, .12); border-radius: $r-card;
background: $stage-paper;
}
// 추천 카드는 색이 아니라 테두리와 그림자로만 앞에 선다
.pr__card--pick { border-color: rgba($subject-ink, .32); box-shadow: $shadow-raised; }
.pr__plan {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(8px, 2.4vw, 10px); line-height: 1.2;
color: rgba($subject-ink, .62); // 흰 무대 위 5.12:1
}
.pr__flag {
padding: 0 $sp-1; border: 1px solid rgba($subject-ink, .28); border-radius: $r-pill;
// 행간 1.2 — 이보다 크면 알약이 플랜 이름 줄보다 높아져 추천 카드만 값이 아래로 밀린다 (480 실측 3.5px)
font-weight: 700; font-size: 8px; line-height: 1.2; color: rgba($subject-ink, .62);
}
.pr__price {
margin: $sp-1 0 0; font-weight: 800; font-size: clamp(14px, 4.4vw, 20px); line-height: 1.1;
color: $subject-ink; // 흰 무대 위 18.24:1
}
.pr__per { font-weight: 600; font-size: clamp(7px, 2vw, 9px); line-height: 1; color: rgba($subject-ink, .62); }
.pr__feats { list-style: none; margin: $sp-3 0 0; padding: 0; display: grid; gap: $sp-1; }
.pr__feat {
display: grid; grid-template-columns: 12px 1fr; align-items: center; gap: $sp-1;
font-weight: 500; font-size: clamp(7px, 2.2vw, 9px); line-height: 1.4;
color: rgba($subject-ink, .78); // 흰 무대 위 9.04:1
}
.pr__ico { display: inline-flex; width: 12px; height: 12px; color: $color; } // 장식 아이콘, 흰 무대 위 4.53:1
.pr__ico svg { width: 100%; height: 100%; }
.pr__pick {
margin: $sp-3 0 0; padding: $sp-1 0; width: 100%;
border: 1px solid rgba($subject-ink, .28); border-radius: $r-pill; background: none;
font-family: inherit; font-weight: 700; font-size: clamp(8px, 2.2vw, 10px); line-height: 1.4;
color: $subject-ink; cursor: pointer; // 세 카드가 같은 버튼 — 위계는 테두리·그림자만 진다
}
// 오브 배지 — 추천 카드 머리 위. 흰 바탕이라 가장자리를 잉크로 내려 경계를 세운다
.pr__orb {
position: absolute; top: -14px; left: 50%; width: 28px; height: 28px; border-radius: 50%;
background:
radial-gradient(circle at 34% 26%, rgba(255, 255, 255, .95) 0%, rgba(255, 255, 255, 0) 46%),
radial-gradient(circle at 52% 56%, $color 0%, rgba($color, .9) 54%, rgba($subject-ink, .82) 100%);
box-shadow: 0 4px 12px rgba($color, .45);
transform: translateX(-50%); // 가로 가운데 맞춤은 고정, 세로만 루프가 움직인다
}
// ── 미리보기 루프: 배지가 1초에 한 번 떠올랐다 내려앉는다
.pr.is-demo .pr__orb {
animation-name: prBob; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes prBob {
0% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-6px); }
100% { transform: translateX(-50%) translateY(0); }
}
// ── 320×200 폰 압축 (BD9). 이 안에서는 간격을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.pr__title { font-size: 11px; }
.pr__sub { font-size: 8px; margin-top: 0; }
.pr__grid { gap: $sp-2; margin-top: $sp-2; padding-top: $sp-4; }
.pr__card { padding: $sp-4 $sp-2 $sp-2; }
.pr__plan { font-size: 8px; }
.pr__flag { font-size: 7px; }
// 320×200 실측 207px(7px 넘침) → 아래 세 간격만 내려 195px 로 맞췄다 (올리지 않는다, BD9)
.pr__price { font-size: 13px; margin-top: 0; }
.pr__feats { margin-top: $sp-1; }
.pr__feat { grid-template-columns: 10px 1fr; font-size: 7px; }
.pr__ico { width: 10px; height: 10px; }
.pr__pick { margin-top: $sp-1; font-size: 8px; }
.pr__orb { top: -10px; width: 20px; height: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.pr.is-demo .pr__orb { animation: none !important; transform: translateX(-50%) !important; }
}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);
}
.pr {
width: min(100%, 432px);
}
.pr__head {
margin: 0;
text-align: center;
}
.pr__title {
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.25;
color: #17141a;
}
.pr__sub {
margin: 4px 0 0;
font-weight: 500;
font-size: clamp(8px, 2.4vw, 10px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
}
.pr__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 12px 0 0;
padding-top: 20px;
}
.pr__card {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 20px 12px 12px;
border: 1px solid rgba(23, 20, 26, 0.12);
border-radius: 16px;
background: #ffffff;
}
.pr__card--pick {
border-color: rgba(23, 20, 26, 0.32);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.pr__plan {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(8px, 2.4vw, 10px);
line-height: 1.2;
color: rgba(23, 20, 26, 0.62);
}
.pr__flag {
padding: 0 4px;
border: 1px solid rgba(23, 20, 26, 0.28);
border-radius: 999px;
font-weight: 700;
font-size: 8px;
line-height: 1.2;
color: rgba(23, 20, 26, 0.62);
}
.pr__price {
margin: 4px 0 0;
font-weight: 800;
font-size: clamp(14px, 4.4vw, 20px);
line-height: 1.1;
color: #17141a;
}
.pr__per {
font-weight: 600;
font-size: clamp(7px, 2vw, 9px);
line-height: 1;
color: rgba(23, 20, 26, 0.62);
}
.pr__feats {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: grid;
gap: 4px;
}
.pr__feat {
display: grid;
grid-template-columns: 12px 1fr;
align-items: center;
gap: 4px;
font-weight: 500;
font-size: clamp(7px, 2.2vw, 9px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.78);
}
.pr__ico {
display: inline-flex;
width: 12px;
height: 12px;
color: #2f6df6;
}
.pr__ico svg {
width: 100%;
height: 100%;
}
.pr__pick {
margin: 12px 0 0;
padding: 4px 0;
width: 100%;
border: 1px solid rgba(23, 20, 26, 0.28);
border-radius: 999px;
background: none;
font-family: inherit;
font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1.4;
color: #17141a;
cursor: pointer;
}
.pr__orb {
position: absolute;
top: -14px;
left: 50%;
width: 28px;
height: 28px;
border-radius: 50%;
background: radial-gradient(circle at 34% 26%, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0) 46%), radial-gradient(circle at 52% 56%, #2f6df6 0%, rgba(47, 109, 246, 0.9) 54%, rgba(23, 20, 26, 0.82) 100%);
box-shadow: 0 4px 12px rgba(47, 109, 246, 0.45);
transform: translateX(-50%);
}
.pr.is-demo .pr__orb {
animation-name: prBob;
animation-duration: 1s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes prBob {
0% {
transform: translateX(-50%) translateY(0);
}
50% {
transform: translateX(-50%) translateY(-6px);
}
100% {
transform: translateX(-50%) translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.pr__title {
font-size: 11px;
}
.pr__sub {
font-size: 8px;
margin-top: 0;
}
.pr__grid {
gap: 8px;
margin-top: 8px;
padding-top: 16px;
}
.pr__card {
padding: 16px 8px 8px;
}
.pr__plan {
font-size: 8px;
}
.pr__flag {
font-size: 7px;
}
.pr__price {
font-size: 13px;
margin-top: 0;
}
.pr__feats {
margin-top: 4px;
}
.pr__feat {
grid-template-columns: 10px 1fr;
font-size: 7px;
}
.pr__ico {
width: 10px;
height: 10px;
}
.pr__pick {
margin-top: 4px;
font-size: 8px;
}
.pr__orb {
top: -10px;
width: 20px;
height: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.pr.is-demo .pr__orb {
animation: none !important;
transform: translateX(-50%) !important;
}
}import styles from "./PricingOrb.module.scss";
export type PricingOrbProps = {
/** 배지가 한 번 떠올랐다 내려앉는 시간(ms). 2000 의 약수로 둔다 */
duration?: number;
easing?: string;
/** 오브 배지 색. 6자리 hex 로 준다 (#2f6df6) */
color?: string;
};
type Plan = { plan: string; price: string; feats: string[]; recommended: boolean };
const PLANS: Plan[] = [
{ plan: "스타터", price: "₩0", feats: ["하루 20회", "모델 1종", "이메일 문의"], recommended: false },
{ plan: "프로", price: "₩19,000", feats: ["하루 500회", "모델 4종", "우선 응답"], recommended: true },
{ plan: "팀", price: "₩49,000", feats: ["무제한 호출", "팀 자리 10", "전담 담당자"], recommended: false },
];
// 6자리 hex 에 알파 두 자리를 붙인다 — #2f6df6 + "73" 꼴
function withAlpha(hex: string, alpha: number): string {
const n = Math.round(alpha * 255);
return hex + (n < 16 ? "0" : "") + n.toString(16);
}
function CheckIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg>
);
}
export default function PricingOrb({
duration = 1000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: PricingOrbProps) {
const orbStyle = {
background:
"radial-gradient(circle at 34% 26%, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0) 46%), " +
"radial-gradient(circle at 52% 56%, " + color + " 0%, " + withAlpha(color, 0.9) + " 54%, rgba(23, 20, 26, 0.82) 100%)",
boxShadow: "0 4px 12px " + withAlpha(color, 0.45),
animationDuration: duration + "ms",
animationTimingFunction: easing,
};
return (
<main className={styles.stage}>
<section className={`${styles.pr} ${styles.demoLoop}`}>
<header className={styles.head}>
<h2 className={styles.title}>요금제</h2>
<p className={styles.sub}>쓰는 만큼만 냅니다.</p>
</header>
<div className={styles.grid}>
{PLANS.map((p) => (
<article key={p.plan} className={`${styles.card} ${p.recommended ? styles.cardPick : ""}`}>
{p.recommended ? <span className={styles.orb} aria-hidden="true" style={orbStyle} /> : null}
<p className={styles.plan}>
{p.plan}
{p.recommended ? <span className={styles.flag}>추천</span> : null}
</p>
<p className={styles.price}>
{p.price}
<span className={styles.per}>/월</span>
</p>
<ul className={styles.feats}>
{p.feats.map((f) => (
<li key={f} className={styles.feat}>
<span className={styles.ico} aria-hidden="true"><CheckIcon /></span>
{f}
</li>
))}
</ul>
<button type="button" className={styles.pick} onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); }}>
고르기
</button>
</article>
))}
</div>
</section>
</main>
);
}05 · Orb-badge pricing — Measured area 0.681% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
At 0.681 percent of moved area this is the quietest thing in the kit, and it is the only component whose orb sits on white.
.pr__orb {
position: absolute; top: -14px; left: 50%; width: 28px; height: 28px; border-radius: 50%;
background:
radial-gradient(circle at 34% 26%, rgba(255, 255, 255, .95) 0%, rgba(255, 255, 255, 0) 46%),
radial-gradient(circle at 52% 56%, $color 0%, rgba($color, .9) 54%, rgba($subject-ink, .82) 100%);
box-shadow: 0 4px 12px rgba($color, .45);
transform: translateX(-50%);
}
.pr__card--pick { border-color: rgba($subject-ink, .32); box-shadow: $shadow-raised; }
@keyframes prBob {
0% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-6px); }
100% { transform: translateX(-50%) translateY(0); }
}
06Orb-avatar testimonials
What moves
Each of the three quote cards gets a 28px orb avatar in its own hue — blue, pink, mint — in place of a stock headshot or a customer logo. Hovering a card lifts it 4px and takes its orb from 78 percent opacity to full at 1.18 scale.

<main class="stage">
<section class="tm is-demo" id="tm">
<p class="tm__eyebrow">
<span class="tm__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></span>
People who tried it first
</p>
<div class="tm__grid">
<article class="tm__card">
<span class="tm__orb tm__orb--first" aria-hidden="true"></span>
<p class="tm__quote">Drop in notes, get back the to-dos.</p>
<p class="tm__role">Data analyst</p>
</article>
<article class="tm__card">
<span class="tm__orb tm__orb--second" aria-hidden="true"></span>
<p class="tm__quote">Drafts come in three tones; I pick one.</p>
<p class="tm__role">Product designer</p>
</article>
<article class="tm__card">
<span class="tm__orb tm__orb--third" aria-hidden="true"></span>
<p class="tm__quote">Paste a log and it points at what broke.</p>
<p class="tm__role">Backend dev</p>
</article>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 셋만 바꾼다
$duration: $dur-loop; // 2초 안에 카드 셋이 667ms 간격으로 한 번씩 반응한다
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 오브 아바타 표면 — 하이라이트 한 겹 + 본체 한 겹. 색조만 바꿔 말하는 사람을 구분한다
@mixin orbSkin($hue) {
background:
radial-gradient(circle at 32% 26%, rgba(255, 255, 255, .95) 0%, rgba(255, 255, 255, 0) 44%),
radial-gradient(circle at 52% 56%, $hue 0%, rgba($hue, .5) 58%, rgba($subject-ink, .92) 100%);
box-shadow: 0 0 12px rgba($hue, .42); // 빛 무리. 오브 opacity 가 내려가면 무리도 같이 죽는다
}
.tm { width: min(100%, 432px); }
.tm__eyebrow {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(8px, 2.4vw, 10px); line-height: 1; letter-spacing: .08em;
color: rgba(255, 255, 255, .72); // 잉크 무대 위 9.81:1 — 계산 2026-09-12
}
.tm__ico { display: inline-flex; width: 12px; height: 12px; flex: 0 0 auto; color: $color; } // 장식, 잉크 위 4.03:1
.tm__ico svg { width: 100%; height: 100%; }
.tm__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-3; margin: $sp-3 0 0; }
.tm__card {
box-sizing: border-box; padding: $sp-3; border: 1px solid rgba(255, 255, 255, .1);
border-radius: $r-card; background: rgba(255, 255, 255, .06);
transition: transform $dur-quick $easing;
}
.tm__orb {
display: block; width: 28px; height: 28px; border-radius: 50%; opacity: .78;
transform-origin: 50% 50%; // 아바타는 제 중심에서 커진다 (모서리 origin 은 밀려 보인다)
transition: transform $dur-quick $easing, opacity $dur-quick $easing;
}
.tm__orb--first { @include orbSkin($subject-blue); }
.tm__orb--second { @include orbSkin($subject-pink); }
.tm__orb--third { @include orbSkin($subject-mint); }
.tm__quote {
margin: $sp-2 0 0; font-weight: 600; font-size: clamp(8px, 2.6vw, 10px); line-height: 1.45;
color: $subject-cream; // 카드 바탕(#252228) 위 14.72:1
}
.tm__role {
margin: $sp-2 0 0; font-weight: 500; font-size: clamp(7px, 2vw, 9px); line-height: 1.3;
color: rgba(255, 255, 255, .68); // 카드 바탕 위 7.99:1
}
// 실제 조작 — 손을 올리거나(터치는 :active) 하면 오브가 커지며 밝아지고 카드가 뜬다
.tm__card:hover, .tm__card:active { transform: translateY(-4px); }
.tm__card:hover .tm__orb, .tm__card:active .tm__orb { opacity: 1; transform: scale(1.18); }
// ── 미리보기 루프: 같은 반응을 카드 순서대로 강제한다
// animation 단축은 delay 를 0s 로 리셋하므로 낱개 속성으로. 지연이 걸린 쪽은 fill-mode: backwards 필수
.tm.is-demo .tm__card,
.tm.is-demo .tm__orb {
animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.tm.is-demo .tm__card { animation-name: tmLift; }
.tm.is-demo .tm__orb { animation-name: tmOrbPop; }
.tm.is-demo .tm__card:nth-child(2), .tm.is-demo .tm__card:nth-child(2) .tm__orb { animation-delay: 667ms; }
.tm.is-demo .tm__card:nth-child(3), .tm.is-demo .tm__card:nth-child(3) .tm__orb { animation-delay: 1334ms; }
@keyframes tmLift {
0% { transform: translateY(0); }
12% { transform: translateY(-4px); }
28% { transform: translateY(-4px); }
40% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes tmOrbPop {
0% { opacity: .78; transform: scale(1); }
12% { opacity: 1; transform: scale(1.18); }
28% { opacity: 1; transform: scale(1.14); }
40% { opacity: .78; transform: scale(1); }
100% { opacity: .78; transform: scale(1); }
}
// ── 320×200 폰 압축 (BD9). 이 안에서는 간격을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.tm__eyebrow { font-size: 8px; }
.tm__ico { width: 10px; height: 10px; }
.tm__grid { gap: $sp-2; margin-top: $sp-2; }
.tm__card { padding: $sp-2; }
.tm__orb { width: 20px; height: 20px; }
.tm__quote { font-size: 8px; margin-top: $sp-1; }
.tm__role { font-size: 7px; margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.tm.is-demo .tm__card,
.tm.is-demo .tm__orb { animation: none !important; transform: none !important; opacity: 1 !important; }
.tm__card, .tm__orb { transition: none !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);
}
.tm {
width: min(100%, 432px);
}
.tm__eyebrow {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(8px, 2.4vw, 10px);
line-height: 1;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.72);
}
.tm__ico {
display: inline-flex;
width: 12px;
height: 12px;
flex: 0 0 auto;
color: #2f6df6;
}
.tm__ico svg {
width: 100%;
height: 100%;
}
.tm__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 12px 0 0;
}
.tm__card {
box-sizing: border-box;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tm__orb {
display: block;
width: 28px;
height: 28px;
border-radius: 50%;
opacity: 0.78;
transform-origin: 50% 50%;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tm__orb--first {
background: radial-gradient(circle at 32% 26%, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0) 44%), radial-gradient(circle at 52% 56%, #2f6df6 0%, rgba(47, 109, 246, 0.5) 58%, rgba(23, 20, 26, 0.92) 100%);
box-shadow: 0 0 12px rgba(47, 109, 246, 0.42);
}
.tm__orb--second {
background: radial-gradient(circle at 32% 26%, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0) 44%), radial-gradient(circle at 52% 56%, #ff9ebb 0%, rgba(255, 158, 187, 0.5) 58%, rgba(23, 20, 26, 0.92) 100%);
box-shadow: 0 0 12px rgba(255, 158, 187, 0.42);
}
.tm__orb--third {
background: radial-gradient(circle at 32% 26%, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0) 44%), radial-gradient(circle at 52% 56%, #4cd4a6 0%, rgba(76, 212, 166, 0.5) 58%, rgba(23, 20, 26, 0.92) 100%);
box-shadow: 0 0 12px rgba(76, 212, 166, 0.42);
}
.tm__quote {
margin: 8px 0 0;
font-weight: 600;
font-size: clamp(8px, 2.6vw, 10px);
line-height: 1.45;
color: #fff7e6;
}
.tm__role {
margin: 8px 0 0;
font-weight: 500;
font-size: clamp(7px, 2vw, 9px);
line-height: 1.3;
color: rgba(255, 255, 255, 0.68);
}
.tm__card:hover, .tm__card:active {
transform: translateY(-4px);
}
.tm__card:hover .tm__orb, .tm__card:active .tm__orb {
opacity: 1;
transform: scale(1.18);
}
.tm.is-demo .tm__card,
.tm.is-demo .tm__orb {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tm.is-demo .tm__card {
animation-name: tmLift;
}
.tm.is-demo .tm__orb {
animation-name: tmOrbPop;
}
.tm.is-demo .tm__card:nth-child(2), .tm.is-demo .tm__card:nth-child(2) .tm__orb {
animation-delay: 667ms;
}
.tm.is-demo .tm__card:nth-child(3), .tm.is-demo .tm__card:nth-child(3) .tm__orb {
animation-delay: 1334ms;
}
@keyframes tmLift {
0% {
transform: translateY(0);
}
12% {
transform: translateY(-4px);
}
28% {
transform: translateY(-4px);
}
40% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes tmOrbPop {
0% {
opacity: 0.78;
transform: scale(1);
}
12% {
opacity: 1;
transform: scale(1.18);
}
28% {
opacity: 1;
transform: scale(1.14);
}
40% {
opacity: 0.78;
transform: scale(1);
}
100% {
opacity: 0.78;
transform: scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.tm__eyebrow {
font-size: 8px;
}
.tm__ico {
width: 10px;
height: 10px;
}
.tm__grid {
gap: 8px;
margin-top: 8px;
}
.tm__card {
padding: 8px;
}
.tm__orb {
width: 20px;
height: 20px;
}
.tm__quote {
font-size: 8px;
margin-top: 4px;
}
.tm__role {
font-size: 7px;
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.tm.is-demo .tm__card,
.tm.is-demo .tm__orb {
animation: none !important;
transform: none !important;
opacity: 1 !important;
}
.tm__card, .tm__orb {
transition: none !important;
}
}var tm = document.getElementById('tm');
function stopLoop() { tm.classList.remove('is-demo'); }
tm.addEventListener('pointerenter', stopLoop);
tm.addEventListener('click', stopLoop);import { useState } from "react";
import styles from "./TestimonialOrb.module.scss";
export type TestimonialOrbProps = {
/** 카드 셋이 한 바퀴 도는 시간(ms). 카드 수로 나눈 값이 카드 사이 간격이 된다 */
duration?: number;
easing?: string;
/** 오브 아바타 색조. 6자리 hex 로 준다 */
hues?: string[];
};
type Quote = { quote: string; role: string };
const QUOTES: Quote[] = [
{ quote: "회의록을 넣으면 할 일만 추려서 돌려줘요.", role: "데이터 분석가" },
{ quote: "초안을 세 가지 말투로 받아 골라 씁니다.", role: "제품 디자이너" },
{ quote: "로그를 붙여넣으면 어디가 터졌는지 짚어줘요.", role: "백엔드 개발자" },
];
// 6자리 hex 에 알파 두 자리를 붙인다 — #2f6df6 + "73" 꼴
function withAlpha(hex: string, alpha: number): string {
const n = Math.round(alpha * 255);
return hex + (n < 16 ? "0" : "") + n.toString(16);
}
export default function TestimonialOrb({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
hues = ["#2f6df6", "#ff9ebb", "#4cd4a6"],
}: TestimonialOrbProps) {
const [demo, setDemo] = useState(true);
const stopLoop = () => { setDemo(false); };
const orbSkin = (hue: string) => ({
background:
"radial-gradient(circle at 32% 26%, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0) 44%), " +
"radial-gradient(circle at 52% 56%, " + hue + " 0%, " + withAlpha(hue, 0.5) + " 58%, rgba(23, 20, 26, 0.92) 100%)",
boxShadow: "0 0 12px " + withAlpha(hue, 0.42),
});
// animation 단축을 쓰면 delay 가 0s 로 리셋된다 → 낱개로. 지연이 걸리므로 fill-mode 는 scss 쪽에서 backwards
const loop = (i: number) => ({
animationDuration: duration + "ms",
animationTimingFunction: easing,
animationDelay: Math.round((duration / QUOTES.length) * i) + "ms",
});
return (
<main className={styles.stage}>
<section
className={`${styles.tm} ${demo ? styles.demoLoop : ""}`}
onPointerEnter={stopLoop}
onClick={stopLoop}
>
<p className={styles.eyebrow}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg>
</span>
먼저 써 본 사람들
</p>
<div className={styles.grid}>
{QUOTES.map((q, i) => (
<article key={q.role} className={styles.card} style={demo ? loop(i) : undefined}>
<span
className={styles.orb}
aria-hidden="true"
style={demo ? { ...orbSkin(hues[i % hues.length]), ...loop(i) } : orbSkin(hues[i % hues.length])}
/>
<p className={styles.quote}>{q.quote}</p>
<p className={styles.role}>{q.role}</p>
</article>
))}
</div>
</section>
</main>
);
}06 · Orb-avatar testimonials — Measured area 10.181% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The preview steps through the three cards 667ms apart, which is the two-second loop divided three ways.
.tm__orb--first { @include orbSkin($subject-blue); }
.tm__orb--second { @include orbSkin($subject-pink); }
.tm__orb--third { @include orbSkin($subject-mint); }
.tm__card:hover .tm__orb, .tm__card:active .tm__orb { opacity: 1; transform: scale(1.18); }
@keyframes tmOrbPop {
0% { opacity: .78; transform: scale(1); }
12% { opacity: 1; transform: scale(1.18); }
28% { opacity: 1; transform: scale(1.14); }
40% { opacity: .78; transform: scale(1); }
100% { opacity: .78; transform: scale(1); }
}
07Drifting-orb CTA band
What you see
A single 208px orb drifts behind the band, 34 percent of its own width to either side of center, which is why this one section moves 33.242 percent of the frame. overflow: hidden on the band clips it, so the light never leaks onto the orange page around it.

<main class="stage">
<section class="cb is-demo" id="cb">
<!-- 오브는 순수 장식 — 낭독기에서 숨긴다 -->
<span class="cb__orb" aria-hidden="true"></span>
<p class="cb__eyebrow">
<span class="cb__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg></span>
Free trial open
</p>
<h2 class="cb__title">Run your first prompt before today ends</h2>
<p class="cb__sub">No card needed — your team workspace opens in five minutes.</p>
<button type="button" class="cb__cta" id="cbCta">
Start for free
<span class="cb__ico cb__ico--sm" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,128l-72,72V56Z" opacity="0.2"/><path d="M221.66,122.34l-72-72A8,8,0,0,0,136,56v64H40a8,8,0,0,0,0,16h96v64a8,8,0,0,0,13.66,5.66l72-72A8,8,0,0,0,221.66,122.34ZM152,180.69V75.31L204.69,128Z"/></svg></span>
</button>
<p class="cb__note">Cancel anytime</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 오브가 좌 → 우 → 좌 를 한 바퀴 도는 시간
$easing: $ease-spring; // 버튼이 떠오르는 이징
$color: $subject-lilac; // 오브 핵심 색
@include stage($stage-orange);
.cb {
position: relative; overflow: hidden; // 오브가 밴드 밖으로 새지 않게
box-sizing: border-box; width: min(100%, 420px);
background: $subject-ink; border-radius: $r-card; box-shadow: $shadow-raised;
padding: $sp-5; text-align: center;
}
// ── 오브: 겹친 radial-gradient 한 덩어리. 필터도 이미지도 없이 그라디언트만으로 공처럼 보이게 한다
// 핵심 알파를 .55 로 묶으면 오브 한가운데가 가장 밝을 때도 글자가 AA 를 넘는다 —
// 렌더 픽셀 실측(오브를 가운데 세우고 글자를 감춘 뒤, 요소마다 그 띠에서 가장 밝은 배경을 샘플).
// 네 요소는 오브의 서로 다른 높이에 걸치므로 배경도 각각 다르다 — 한 배경을 공유하지 않는다:
// 제목 크림 100% / #53528a → 6.70:1
// 부제 크림 100% / #6e6096 → 5.22:1 (오브 핵을 정면으로 지나 가장 밝은 자리)
// 눈썹 크림 72% / #475c90 → 6.32:1
// 주석 크림 62% / #1e305f → 5.59:1
// 부제만 크림 72% 로 두었을 때 3.58:1 이라 알파를 100% 로 올렸다 (11px 500 은 큰 글자 예외도 못 받는다).
// 위계는 색이 아니라 크기(19px↔11px)와 굵기(800↔500)가 만든다. (오브 없는 잉크 바탕에서는 17.11:1)
.cb__orb {
position: absolute; left: 50%; top: 50%;
margin-left: -104px; margin-top: -104px; // 자기 크기의 절반만큼 당겨 가운데로
width: 208px; height: 208px; border-radius: 50%; z-index: 0; pointer-events: none;
background: radial-gradient(circle at 50% 50%,
rgba($color, .55) 0%,
rgba($subject-blue, .40) 38%,
rgba($subject-blue, 0) 70%);
// 유영은 부품의 기본 성질 — .is-demo 와 무관하게 항상 돈다
animation-name: orbDrift;
animation-duration: $duration;
animation-timing-function: ease-in-out; // 왕복이라 양 끝에서 느려져야 한다 ($ease-out 은 편도용)
animation-iteration-count: infinite;
}
// 왕복이라 되돌아오는 경로가 그대로 보인다 — 끝에서 처음으로 튀는 구간이 없다
@keyframes orbDrift {
0% { transform: translateX(-34%); }
50% { transform: translateX(34%); }
100% { transform: translateX(-34%); }
}
.cb__eyebrow,
.cb__title,
.cb__sub,
.cb__cta,
.cb__note { position: relative; z-index: 1; }
.cb__eyebrow {
display: inline-flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(9px, 2.6vw, 10px); line-height: 1;
letter-spacing: .04em; color: rgba($subject-cream, .72); // 눈썹 띠 최악 배경 #475c90 위 6.32:1
}
.cb__ico { display: inline-flex; width: 14px; height: 14px; flex: 0 0 auto; }
.cb__ico--sm { width: 12px; height: 12px; }
.cb__ico svg { width: 100%; height: 100%; }
.cb__title {
margin: $sp-2 0 0; font-weight: 800; font-size: clamp(14px, 4.2vw, 19px); line-height: 1.25;
color: $subject-cream; // 제목 띠 최악 배경 #53528a 위 6.70:1 (잉크 바탕 17.11:1)
}
.cb__sub {
margin: $sp-2 0 0; font-weight: 500; font-size: clamp(9px, 2.8vw, 11px); line-height: 1.45;
color: $subject-cream; // 부제 띠 최악 배경 #6e6096 위 5.22:1 (72% 였을 때 3.58:1 미달)
}
// ── 솔리드 버튼: 빛나지 않는다. 떠오름은 transform, 깊이는 그림자
.cb__cta {
display: inline-flex; align-items: center; gap: $sp-2; margin-top: $sp-4;
padding: $sp-3 $sp-6; border: 0; border-radius: $r-control; cursor: pointer;
background: $subject-cream; color: $subject-ink; // 17.11:1
font-family: inherit; font-weight: 800; font-size: clamp(11px, 3.2vw, 13px); line-height: 1;
box-shadow: $shadow-press;
transition: transform $dur-quick $easing, box-shadow $dur-quick $easing;
}
.cb__cta:hover,
.cb__cta:focus-visible { transform: translateY(-1px); box-shadow: $shadow-raised; }
.cb__cta:active { transform: translateY(0); box-shadow: $shadow-press; }
.cb__note {
margin: $sp-2 0 0; font-weight: 600; font-size: clamp(8px, 2.4vw, 9px); line-height: 1;
color: rgba($subject-cream, .62); // 주석 띠 최악 배경 #1e305f 위 5.59:1
}
// ── 미리보기 루프: 호버를 못 하는 격자 칸에서도 "떠오르는 버튼" 을 보여준다 (복귀 90% 안)
.cb.is-demo .cb__cta {
animation-name: ctaLift;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ctaLift {
0%, 40% { transform: translateY(0); box-shadow: $shadow-press; }
54%, 84% { transform: translateY(-1px); box-shadow: $shadow-raised; }
90%, 100% { transform: translateY(0); box-shadow: $shadow-press; }
}
// ── 320×200 폰 실측 대비 압축 (BD9). 이 블록 안에서는 간격을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.cb { padding: $sp-3; }
.cb__orb { width: 160px; height: 160px; margin-left: -80px; margin-top: -80px; }
.cb__eyebrow { font-size: 8px; }
.cb__ico { width: 12px; height: 12px; }
.cb__title { font-size: 12px; margin-top: $sp-1; }
.cb__sub { font-size: 8px; margin-top: $sp-1; }
.cb__cta { margin-top: $sp-2; padding: $sp-2 $sp-4; font-size: 10px; }
.cb__note { font-size: 7px; margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.cb__orb { animation: none !important; transform: none !important; }
.cb.is-demo .cb__cta { animation: none !important; }
.cb__cta { transition: none !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);
}
.cb {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(100%, 420px);
background: #17141a;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 20px;
text-align: center;
}
.cb__orb {
position: absolute;
left: 50%;
top: 50%;
margin-left: -104px;
margin-top: -104px;
width: 208px;
height: 208px;
border-radius: 50%;
z-index: 0;
pointer-events: none;
background: radial-gradient(circle at 50% 50%, rgba(185, 160, 255, 0.55) 0%, rgba(47, 109, 246, 0.4) 38%, rgba(47, 109, 246, 0) 70%);
animation-name: orbDrift;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes orbDrift {
0% {
transform: translateX(-34%);
}
50% {
transform: translateX(34%);
}
100% {
transform: translateX(-34%);
}
}
.cb__eyebrow,
.cb__title,
.cb__sub,
.cb__cta,
.cb__note {
position: relative;
z-index: 1;
}
.cb__eyebrow {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(9px, 2.6vw, 10px);
line-height: 1;
letter-spacing: 0.04em;
color: rgba(255, 247, 230, 0.72);
}
.cb__ico {
display: inline-flex;
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.cb__ico--sm {
width: 12px;
height: 12px;
}
.cb__ico svg {
width: 100%;
height: 100%;
}
.cb__title {
margin: 8px 0 0;
font-weight: 800;
font-size: clamp(14px, 4.2vw, 19px);
line-height: 1.25;
color: #fff7e6;
}
.cb__sub {
margin: 8px 0 0;
font-weight: 500;
font-size: clamp(9px, 2.8vw, 11px);
line-height: 1.45;
color: #fff7e6;
}
.cb__cta {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 0;
border-radius: 12px;
cursor: pointer;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cb__cta:hover,
.cb__cta:focus-visible {
transform: translateY(-1px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cb__cta:active {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cb__note {
margin: 8px 0 0;
font-weight: 600;
font-size: clamp(8px, 2.4vw, 9px);
line-height: 1;
color: rgba(255, 247, 230, 0.62);
}
.cb.is-demo .cb__cta {
animation-name: ctaLift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ctaLift {
0%, 40% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
54%, 84% {
transform: translateY(-1px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
90%, 100% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@media (max-width: 340px), (max-height: 220px) {
.cb {
padding: 12px;
}
.cb__orb {
width: 160px;
height: 160px;
margin-left: -80px;
margin-top: -80px;
}
.cb__eyebrow {
font-size: 8px;
}
.cb__ico {
width: 12px;
height: 12px;
}
.cb__title {
font-size: 12px;
margin-top: 4px;
}
.cb__sub {
font-size: 8px;
margin-top: 4px;
}
.cb__cta {
margin-top: 8px;
padding: 8px 16px;
font-size: 10px;
}
.cb__note {
font-size: 7px;
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb__orb {
animation: none !important;
transform: none !important;
}
.cb.is-demo .cb__cta {
animation: none !important;
}
.cb__cta {
transition: none !important;
}
}// 사람이 손을 대면 미리보기 루프를 끄고 진짜 호버·포커스에 넘긴다
var band = document.getElementById('cb');
var cta = document.getElementById('cbCta');
function stopPreview() { band.classList.remove('is-demo'); }
cta.addEventListener('pointerenter', stopPreview);
cta.addEventListener('focus', stopPreview);
cta.addEventListener('click', stopPreview);import { useState } from "react";
import styles from "./CtaBandOrb.module.scss";
export type CtaBandOrbProps = {
/** 오브가 좌 → 우 → 좌 를 한 바퀴 도는 시간 */
duration?: string;
/** 버튼이 떠오르는 이징 */
easing?: string;
/** 오브 핵심 색 — --orbColor 로 내려간다 */
color?: string;
};
export default function CtaBandOrb({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#b9a0ff",
}: CtaBandOrbProps) {
// 사람이 손을 대면 미리보기 루프를 끄고 진짜 호버·포커스에 넘긴다
const [preview, setPreview] = useState(true);
const stopPreview = () => { setPreview(false); };
return (
<main className={styles.stage}>
<section className={styles.cb} style={{ "--orbColor": color }}>
{/* 오브는 순수 장식 — 낭독기에서 숨긴다 */}
<span
className={styles.orb}
aria-hidden="true"
style={{
animationName: "orbDrift",
animationDuration: duration,
animationTimingFunction: "ease-in-out",
animationIterationCount: "infinite",
}}
/>
<p className={styles.eyebrow}>
<span className={styles.ico} aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg></span>
무료 체험 열림
</p>
<h2 className={styles.title}>첫 프롬프트를 오늘 안에 돌려보세요</h2>
<p className={styles.sub}>카드 등록 없이 5분이면 팀 작업실이 열립니다.</p>
<button
type="button"
className={styles.cta}
style={preview ? {
animationName: "ctaLift",
animationDuration: duration,
animationTimingFunction: easing,
animationIterationCount: "infinite",
} : undefined}
onPointerEnter={stopPreview}
onFocus={stopPreview}
onClick={stopPreview}
>
무료로 시작하기
<span className={`${styles.ico} ${styles.icoSm}`} aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,128l-72,72V56Z" opacity="0.2"/><path d="M221.66,122.34l-72-72A8,8,0,0,0,136,56v64H40a8,8,0,0,0,0,16h96v64a8,8,0,0,0,13.66,5.66l72-72A8,8,0,0,0,221.66,122.34ZM152,180.69V75.31L204.69,128Z"/></svg></span>
</button>
<p className={styles.note}>언제든 해지할 수 있어요</p>
</section>
</main>
);
}07 · Drifting-orb CTA band — Measured area 33.242% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The button in front is solid cream and lifts 1px on hover — in this style the button is the one thing that must not glow.
.cb__orb {
position: absolute; left: 50%; top: 50%;
margin-left: -104px; margin-top: -104px;
width: 208px; height: 208px; border-radius: 50%; z-index: 0; pointer-events: none;
background: radial-gradient(circle at 50% 50%,
rgba($color, .55) 0%,
rgba($subject-blue, .40) 38%,
rgba($subject-blue, 0) 70%);
animation-name: orbDrift;
animation-duration: $duration;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes orbDrift {
0% { transform: translateX(-34%); }
50% { transform: translateX(34%); }
100% { transform: translateX(-34%); }
}
08Orbit-beacon footer
What happens on screen
Three mini orbs travel along the footer's top rule, two of them on a two-second crossing and the quick one on half that. Negative delays of -400ms, -500ms, and -1200ms start them 20, 50, and 60 percent into their own loops, which spaces them along the track at roughly 15, 50, and 62 percent, so the rule is never bare in a screenshot.

<main class="stage">
<footer class="fo">
<!-- 구분선 위를 지나가는 미니 오브 셋 — 장식이라 낭독기에서 숨긴다 -->
<div class="fo__rail" aria-hidden="true">
<span class="fo__beacon fo__beacon--slow"><span class="fo__dot fo__dot--lilac"></span></span>
<span class="fo__beacon fo__beacon--fast"><span class="fo__dot fo__dot--mint"></span></span>
<span class="fo__beacon fo__beacon--drift"><span class="fo__dot fo__dot--blue"></span></span>
</div>
<div class="fo__cols">
<section class="fo__col">
<h2 class="fo__head">Product</h2>
<ul class="fo__links">
<li><a href="#">Features</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Changelog</a></li>
</ul>
</section>
<section class="fo__col">
<h2 class="fo__head">Docs</h2>
<ul class="fo__links">
<li><a href="#">Quickstart</a></li>
<li><a href="#">API</a></li>
<li><a href="#">Examples</a></li>
</ul>
</section>
<section class="fo__col">
<h2 class="fo__head">Company</h2>
<ul class="fo__links">
<li><a href="#">About</a></li>
<li><a href="#">Careers</a></li>
<li><a href="#">Contact</a></li>
</ul>
</section>
<section class="fo__col">
<h2 class="fo__head">Community</h2>
<ul class="fo__links">
<li><a href="#">Discord</a></li>
<li><a href="#">Blog</a></li>
<li><a href="#">Status</a></li>
</ul>
</section>
</div>
<p class="fo__copy">
<span>© 2026 Orbit Labs</span>
<span class="fo__lang">
<span class="fo__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M213.09,172.48a96,96,0,0,1-80.41,51.41l3.17-16.44a8,8,0,0,0-2-6.95l-19.74-20.33a8,8,0,0,1-1.44-8.69l13.7-30.74a8,8,0,0,1,8.38-4.67l22.82,3.08a8.11,8.11,0,0,1,3.12,1.11ZM116.71,95,129,88.24a7.46,7.46,0,0,0,1.5-1.07l26.91-24.33A8,8,0,0,0,159,53l-10.5-18.81A96.62,96.62,0,0,0,128,32,95.61,95.61,0,0,0,67.78,53.23L56,81.08A8,8,0,0,0,55.88,87l11.5,30.67a8,8,0,0,0,5.81,5l2.69.58L89.2,100a8,8,0,0,1,6.94-4h16.71A7.9,7.9,0,0,0,116.71,95Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm88,104a87.62,87.62,0,0,1-6.4,32.94l-44.7-27.49a15.92,15.92,0,0,0-6.24-2.23l-22.82-3.08a16.11,16.11,0,0,0-16,7.86h-8.72l-3.8-7.86a15.91,15.91,0,0,0-11-8.67l-8-1.73L96.14,104h16.71a16.06,16.06,0,0,0,7.73-2l12.25-6.76a16.62,16.62,0,0,0,3-2.14l26.91-24.34A15.93,15.93,0,0,0,166,49.1l-.36-.65A88.11,88.11,0,0,1,216,128ZM143.31,41.34,152,56.9,125.09,81.24,112.85,88H96.14a16,16,0,0,0-13.88,8l-8.73,15.23L63.38,84.19,74.32,58.32a87.87,87.87,0,0,1,69-17ZM40,128a87.53,87.53,0,0,1,8.54-37.8l11.34,30.27a16,16,0,0,0,11.62,10l21.43,4.61L96.74,143a16.09,16.09,0,0,0,14.4,9h1.48l-7.23,16.23a16,16,0,0,0,2.86,17.37l.14.14L128,205.94l-1.94,10A88.11,88.11,0,0,1,40,128Zm102.58,86.78,1.13-5.81a16.09,16.09,0,0,0-4-13.9,1.85,1.85,0,0,1-.14-.14L120,174.74,133.7,144l22.82,3.08,45.72,28.12A88.18,88.18,0,0,1,142.58,214.78Z"/></svg></span>
English
</span>
</p>
</footer>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 느린 오브가 구분선을 한 번 건너는 시간 (빠른 쪽은 이것의 절반)
$easing: linear; // 지나가는 빛은 가속하지 않는다
$color: $subject-lilac; // 첫 오브 색
@include stage($stage-paper);
.fo {
position: relative; overflow: hidden; // 오브가 푸터 밖으로 새지 않게
box-sizing: border-box; width: min(100%, 440px);
background: $subject-ink; border-radius: $r-card; box-shadow: $shadow-raised;
padding: $sp-4 $sp-5 $sp-4;
color: $subject-cream;
}
// ── 상단 구분선 = 오브가 지나가는 궤도
.fo__rail {
position: relative; height: 2px; border-radius: $r-pill;
background: rgba($subject-cream, .18);
}
// 캐리어. 폭이 궤도와 같아서 translateX 의 % 가 곧 "궤도의 몇 %" 가 된다
// (점 자체에 % 를 주면 20·14·24px 짜리 점 자기 폭의 % 라 궤도를 못 건넌다)
.fo__beacon {
position: absolute; left: 0; top: 50%; width: 100%; height: 0;
animation-name: beaconCross;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards; // 지연이 걸린 루프 — 없으면 첫 프레임에 기본 상태가 번쩍인다
}
.fo__beacon--slow { animation-duration: $duration; animation-delay: -400ms; }
.fo__beacon--fast { animation-duration: $dur-loop * .5; animation-delay: -500ms; }
.fo__beacon--drift { animation-duration: $duration; animation-delay: -1200ms; }
// 음수 지연으로 셋의 위상을 20% · 50% · 62% 로 벌려 둔다 — 어느 순간에도 최소 하나는 궤도 안에 있다
// (poster 가 빈 구분선이 되지 않게)
.fo__dot {
position: absolute; left: 0; top: 0; transform: translate(-50%, -50%);
width: 20px; height: 20px; border-radius: 50%;
}
.fo__dot--lilac { background: radial-gradient(circle at 50% 50%, rgba($color, .92) 0%, rgba($color, .34) 42%, rgba($color, 0) 72%); }
.fo__dot--mint { width: 14px; height: 14px;
background: radial-gradient(circle at 50% 50%, rgba($subject-mint, .92) 0%, rgba($subject-mint, .34) 42%, rgba($subject-mint, 0) 72%); }
.fo__dot--blue { width: 24px; height: 24px;
background: radial-gradient(circle at 50% 50%, rgba($subject-sky, .88) 0%, rgba($subject-blue, .34) 42%, rgba($subject-blue, 0) 72%); }
@keyframes beaconCross {
from { transform: translateX(-8%); }
to { transform: translateX(108%); }
}
// ── 네 열
.fo__cols {
display: grid; grid-template-columns: repeat(4, 1fr); gap: $sp-4 $sp-3;
margin-top: $sp-4;
}
.fo__col { min-width: 0; }
.fo__head {
margin: 0; font-weight: 800; font-size: clamp(9px, 2.6vw, 10px); line-height: 1;
letter-spacing: .03em; color: $subject-cream; // 17.11:1
}
.fo__links { list-style: none; margin: $sp-2 0 0; padding: 0; }
.fo__links li { margin-top: $sp-1; }
.fo__links li:first-child { margin-top: 0; }
.fo__links a {
font-weight: 600; font-size: clamp(8px, 2.4vw, 10px); line-height: 1.3;
color: rgba($subject-cream, .72); // 9.18:1
text-decoration: none; transition: color $dur-quick $ease-out;
}
.fo__links a:hover,
.fo__links a:focus-visible { color: $subject-cream; }
.fo__copy {
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
margin: $sp-4 0 0; padding-top: $sp-3;
border-top: 1px solid rgba($subject-cream, .14);
font-weight: 600; font-size: clamp(7px, 2.2vw, 9px); line-height: 1;
color: rgba($subject-cream, .62); // 7.12:1
}
.fo__lang { display: inline-flex; align-items: center; gap: $sp-1; }
.fo__ico { display: inline-flex; width: 12px; height: 12px; }
.fo__ico svg { width: 100%; height: 100%; }
// ── 320×200 폰 실측 대비 압축 (BD9). 네 열이 한 열로 접힌다 — 이 블록 안에서는 간격을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.fo { padding: $sp-3; }
.fo__cols { grid-template-columns: 1fr; gap: $sp-2; margin-top: $sp-3; }
// 한 열이 되면 제목과 링크를 한 줄에 눕혀 네 덩어리가 폰 높이 안에 들어오게 한다
.fo__col { display: flex; align-items: baseline; gap: $sp-2; }
.fo__head { font-size: 8px; flex: 0 0 auto; }
.fo__links { display: flex; flex-wrap: wrap; gap: $sp-2; margin-top: 0; }
.fo__links li { margin-top: 0; }
.fo__links a { font-size: 8px; }
.fo__dot { width: 14px; height: 14px; }
.fo__dot--mint { width: 10px; height: 10px; }
.fo__dot--blue { width: 16px; height: 16px; }
.fo__copy { margin-top: $sp-2; padding-top: $sp-2; font-size: 7px; }
.fo__ico { width: 10px; height: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.fo__beacon { animation: none !important; }
.fo__beacon--slow { transform: translateX(20%) !important; }
.fo__beacon--fast { transform: translateX(50%) !important; }
.fo__beacon--drift { transform: translateX(62%) !important; }
.fo__links a { transition: none !important; }
}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);
}
.fo {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(100%, 440px);
background: #17141a;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 16px 20px 16px;
color: #fff7e6;
}
.fo__rail {
position: relative;
height: 2px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.18);
}
.fo__beacon {
position: absolute;
left: 0;
top: 50%;
width: 100%;
height: 0;
animation-name: beaconCross;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fo__beacon--slow {
animation-duration: 2s;
animation-delay: -400ms;
}
.fo__beacon--fast {
animation-duration: 1s;
animation-delay: -500ms;
}
.fo__beacon--drift {
animation-duration: 2s;
animation-delay: -1200ms;
}
.fo__dot {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
border-radius: 50%;
}
.fo__dot--lilac {
background: radial-gradient(circle at 50% 50%, rgba(185, 160, 255, 0.92) 0%, rgba(185, 160, 255, 0.34) 42%, rgba(185, 160, 255, 0) 72%);
}
.fo__dot--mint {
width: 14px;
height: 14px;
background: radial-gradient(circle at 50% 50%, rgba(76, 212, 166, 0.92) 0%, rgba(76, 212, 166, 0.34) 42%, rgba(76, 212, 166, 0) 72%);
}
.fo__dot--blue {
width: 24px;
height: 24px;
background: radial-gradient(circle at 50% 50%, rgba(110, 200, 255, 0.88) 0%, rgba(47, 109, 246, 0.34) 42%, rgba(47, 109, 246, 0) 72%);
}
@keyframes beaconCross {
from {
transform: translateX(-8%);
}
to {
transform: translateX(108%);
}
}
.fo__cols {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px 12px;
margin-top: 16px;
}
.fo__col {
min-width: 0;
}
.fo__head {
margin: 0;
font-weight: 800;
font-size: clamp(9px, 2.6vw, 10px);
line-height: 1;
letter-spacing: 0.03em;
color: #fff7e6;
}
.fo__links {
list-style: none;
margin: 8px 0 0;
padding: 0;
}
.fo__links li {
margin-top: 4px;
}
.fo__links li:first-child {
margin-top: 0;
}
.fo__links a {
font-weight: 600;
font-size: clamp(8px, 2.4vw, 10px);
line-height: 1.3;
color: rgba(255, 247, 230, 0.72);
text-decoration: none;
transition: color 220ms ease-out;
}
.fo__links a:hover,
.fo__links a:focus-visible {
color: #fff7e6;
}
.fo__copy {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 16px 0 0;
padding-top: 12px;
border-top: 1px solid rgba(255, 247, 230, 0.14);
font-weight: 600;
font-size: clamp(7px, 2.2vw, 9px);
line-height: 1;
color: rgba(255, 247, 230, 0.62);
}
.fo__lang {
display: inline-flex;
align-items: center;
gap: 4px;
}
.fo__ico {
display: inline-flex;
width: 12px;
height: 12px;
}
.fo__ico svg {
width: 100%;
height: 100%;
}
@media (max-width: 340px), (max-height: 220px) {
.fo {
padding: 12px;
}
.fo__cols {
grid-template-columns: 1fr;
gap: 8px;
margin-top: 12px;
}
.fo__col {
display: flex;
align-items: baseline;
gap: 8px;
}
.fo__head {
font-size: 8px;
flex: 0 0 auto;
}
.fo__links {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 0;
}
.fo__links li {
margin-top: 0;
}
.fo__links a {
font-size: 8px;
}
.fo__dot {
width: 14px;
height: 14px;
}
.fo__dot--mint {
width: 10px;
height: 10px;
}
.fo__dot--blue {
width: 16px;
height: 16px;
}
.fo__copy {
margin-top: 8px;
padding-top: 8px;
font-size: 7px;
}
.fo__ico {
width: 10px;
height: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fo__beacon {
animation: none !important;
}
.fo__beacon--slow {
transform: translateX(20%) !important;
}
.fo__beacon--fast {
transform: translateX(50%) !important;
}
.fo__beacon--drift {
transform: translateX(62%) !important;
}
.fo__links a {
transition: none !important;
}
}import styles from "./FooterOrb.module.scss";
export type FooterOrbProps = {
/** 느린 오브가 구분선을 한 번 건너는 시간 (빠른 쪽은 이것의 절반) */
duration?: string;
/** 지나가는 빛의 이징 — 가속하지 않는다 */
easing?: string;
/** 첫 오브 색 — --beaconColor 로 내려간다 */
color?: string;
};
type Column = { head: string; links: string[] };
const COLUMNS: Column[] = [
{ head: "제품", links: ["기능", "요금제", "변경 기록"] },
{ head: "문서", links: ["빠른 시작", "API", "예제"] },
{ head: "회사", links: ["소개", "채용", "문의"] },
{ head: "커뮤니티", links: ["디스코드", "블로그", "상태"] },
];
// 음수 지연으로 셋의 위상을 20% · 50% · 62% 로 벌려 둔다 — 어느 순간에도 최소 하나는 궤도 안에 있다
const BEACONS = [
{ dot: styles.dotLilac, delay: "-400ms", half: false },
{ dot: styles.dotMint, delay: "-500ms", half: true },
{ dot: styles.dotBlue, delay: "-1200ms", half: false },
];
function halfOf(duration: string): string {
const n = parseFloat(duration);
return duration.indexOf("ms") >= 0 ? `${n / 2}ms` : `${n / 2}s`;
}
export default function FooterOrb({
duration = "2s",
easing = "linear",
color = "#b9a0ff",
}: FooterOrbProps) {
return (
<main className={styles.stage}>
<footer className={styles.fo} style={{ "--beaconColor": color }}>
{/* 구분선 위를 지나가는 미니 오브 셋 — 장식이라 낭독기에서 숨긴다 */}
<div className={styles.rail} aria-hidden="true">
{BEACONS.map((b) => (
<span
key={b.delay}
className={styles.beacon}
style={{
animationName: "beaconCross",
animationDuration: b.half ? halfOf(duration) : duration,
animationTimingFunction: easing,
animationIterationCount: "infinite",
animationFillMode: "backwards",
animationDelay: b.delay,
}}
>
<span className={`${styles.dot} ${b.dot}`} />
</span>
))}
</div>
<div className={styles.cols}>
{COLUMNS.map((c) => (
<section key={c.head} className={styles.col}>
<h2 className={styles.head}>{c.head}</h2>
<ul className={styles.links}>
{c.links.map((l) => (
<li key={l}><a href="#" onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); }}>{l}</a></li>
))}
</ul>
</section>
))}
</div>
<p className={styles.copy}>
<span>© 2026 Orbit Labs</span>
<span className={styles.lang}>
<span className={styles.ico} aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M213.09,172.48a96,96,0,0,1-80.41,51.41l3.17-16.44a8,8,0,0,0-2-6.95l-19.74-20.33a8,8,0,0,1-1.44-8.69l13.7-30.74a8,8,0,0,1,8.38-4.67l22.82,3.08a8.11,8.11,0,0,1,3.12,1.11ZM116.71,95,129,88.24a7.46,7.46,0,0,0,1.5-1.07l26.91-24.33A8,8,0,0,0,159,53l-10.5-18.81A96.62,96.62,0,0,0,128,32,95.61,95.61,0,0,0,67.78,53.23L56,81.08A8,8,0,0,0,55.88,87l11.5,30.67a8,8,0,0,0,5.81,5l2.69.58L89.2,100a8,8,0,0,1,6.94-4h16.71A7.9,7.9,0,0,0,116.71,95Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm88,104a87.62,87.62,0,0,1-6.4,32.94l-44.7-27.49a15.92,15.92,0,0,0-6.24-2.23l-22.82-3.08a16.11,16.11,0,0,0-16,7.86h-8.72l-3.8-7.86a15.91,15.91,0,0,0-11-8.67l-8-1.73L96.14,104h16.71a16.06,16.06,0,0,0,7.73-2l12.25-6.76a16.62,16.62,0,0,0,3-2.14l26.91-24.34A15.93,15.93,0,0,0,166,49.1l-.36-.65A88.11,88.11,0,0,1,216,128ZM143.31,41.34,152,56.9,125.09,81.24,112.85,88H96.14a16,16,0,0,0-13.88,8l-8.73,15.23L63.38,84.19,74.32,58.32a87.87,87.87,0,0,1,69-17ZM40,128a87.53,87.53,0,0,1,8.54-37.8l11.34,30.27a16,16,0,0,0,11.62,10l21.43,4.61L96.74,143a16.09,16.09,0,0,0,14.4,9h1.48l-7.23,16.23a16,16,0,0,0,2.86,17.37l.14.14L128,205.94l-1.94,10A88.11,88.11,0,0,1,40,128Zm102.58,86.78,1.13-5.81a16.09,16.09,0,0,0-4-13.9,1.85,1.85,0,0,1-.14-.14L120,174.74,133.7,144l22.82,3.08,45.72,28.12A88.18,88.18,0,0,1,142.58,214.78Z"/></svg></span>
한국어
</span>
</p>
</footer>
</main>
);
}08 · Orbit-beacon footer — Measured area 6.163% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Each dot rides a carrier that is as wide as the rule itself, which is what lets a translateX percentage mean percent-of-track rather than percent-of-dot.
.fo__beacon {
position: absolute; left: 0; top: 50%; width: 100%; height: 0;
animation-name: beaconCross;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fo__beacon--slow { animation-duration: $duration; animation-delay: -400ms; }
.fo__beacon--fast { animation-duration: $dur-loop * .5; animation-delay: -500ms; }
.fo__beacon--drift { animation-duration: $duration; animation-delay: -1200ms; }
@keyframes beaconCross {
from { transform: translateX(-8%); }
to { transform: translateX(108%); }
}
09Orb buttons & chat input
What moves
The rounded chat field grows a single 4px halo ring the moment anything inside it takes focus, and the 8px dot tucked into the send button jumps to 1.55 scale when the button is pressed. One ring, never two — stack a second layer and the input turns into an orb of its own, competing with the real ones.

<main class="stage">
<div class="ch is-demo" id="ch">
<div class="ch__field">
<span class="ch__lead" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></span>
<span class="ch__box">
<input class="ch__input" id="chInput" type="text" placeholder="Ask anything" aria-label="What to ask the AI">
<!-- 미리보기용 고스트 글자. .is-demo 동안만 보이고 그때 placeholder 는 투명해진다 -->
<span class="ch__ghost" aria-hidden="true">How do I lift this landing page's conversion rate?</span>
</span>
<button type="button" class="ch__send" id="chSend" aria-label="Send">
<span class="ch__dot" aria-hidden="true"></span>
<span class="ch__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M223.69,42.18l-58.22,192a8,8,0,0,1-14.92,1.25L108,148,20.58,105.45a8,8,0,0,1,1.25-14.92l192-58.22A8,8,0,0,1,223.69,42.18Z" opacity="0.2"/><path d="M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z"/></svg></span>
</button>
</div>
<div class="ch__row">
<button type="button" class="ch__btn ch__btn--solid">Get an answer</button>
<button type="button" class="ch__btn ch__btn--quiet">Example prompts</button>
</div>
<p class="ch__hint">Answers usually arrive within 2 seconds</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 묻고 → 보내고 → 돌아오는 한 바퀴
$easing: $ease-out; // 빛 무리가 번지는 이징
$color: $subject-lilac; // 빛 무리 색
@include stage($stage-ink);
.ch { box-sizing: border-box; width: min(100%, 400px); }
// ── 둥근 챗 입력창. 포커스 링은 box-shadow 한 겹뿐 — 두 겹 이상 쌓으면 "빛나는 입력창" 이 된다
.ch__field {
display: flex; align-items: center; gap: $sp-2;
padding: $sp-2 $sp-2 $sp-2 $sp-4;
border: 1px solid rgba($subject-cream, .18);
border-radius: $r-pill;
background: rgba($subject-cream, .08);
box-shadow: 0 0 0 0 rgba($color, 0);
transition: box-shadow $dur-base $easing, border-color $dur-base $easing;
}
.ch__field:focus-within {
border-color: rgba($color, .62);
box-shadow: 0 0 0 4px rgba($color, .34);
}
.ch__lead { display: inline-flex; width: 16px; height: 16px; flex: 0 0 auto; color: rgba($subject-cream, .62); }
.ch__lead svg { width: 100%; height: 100%; }
.ch__box { position: relative; display: block; flex: 1 1 auto; min-width: 0; }
.ch__input {
display: block; width: 100%; border: 0; background: none; padding: 0;
font-family: inherit; font-weight: 600; font-size: clamp(10px, 2.9vw, 12px); line-height: 1.6;
color: $subject-cream; // 13.96:1
}
.ch__input:focus { outline: none; } // 링을 field 가 그리므로 기본 아웃라인은 뺀다
.ch__input::placeholder { color: rgba($subject-cream, .62); opacity: 1; } // 6.29:1
.ch__ghost {
position: absolute; left: 0; top: 0; width: 100%; opacity: 0;
font-weight: 600; font-size: clamp(10px, 2.9vw, 12px); line-height: 1.6;
color: $subject-cream; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
pointer-events: none;
}
// 고스트 글자가 보일 땐 placeholder 를 투명하게 — 두 글자가 겹치면 둘 다 못 읽는다
.ch.is-demo .ch__input::placeholder { color: transparent; }
// ── 전송 버튼: 솔리드. 안의 작은 오브 점이 눌림을 표시한다
.ch__send {
position: relative; display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; flex: 0 0 auto; border: 0; border-radius: 50%; cursor: pointer;
background: $subject-cream; color: $subject-ink; // 아이콘 17.11:1
transition: transform $dur-instant $easing;
}
.ch__ico { display: inline-flex; width: 15px; height: 15px; }
.ch__ico svg { width: 100%; height: 100%; }
.ch__dot {
position: absolute; right: 5px; top: 5px;
width: 8px; height: 8px; border-radius: 50%; opacity: .6;
background: radial-gradient(circle at 50% 50%, rgba($subject-sky, 1) 0%, rgba($subject-blue, 1) 70%);
transform-origin: 50% 50%; // 스스로 완결한 점 — 제 중심에서 커진다
transition: transform $dur-instant $easing, opacity $dur-instant $easing;
}
.ch__send:active { transform: translateY(1px); }
.ch__send:active .ch__dot { transform: scale(1.55); opacity: 1; }
.ch__send:focus-visible { outline: 2px solid rgba($color, .8); outline-offset: 2px; }
// ── 솔리드 버튼 둘
.ch__row { display: flex; gap: $sp-2; margin-top: $sp-3; }
.ch__btn {
flex: 1 1 0; padding: $sp-3 $sp-2; border: 0; border-radius: $r-control; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.9vw, 12px); line-height: 1;
transition: transform $dur-quick $easing, box-shadow $dur-quick $easing;
}
.ch__btn--solid { background: $subject-cream; color: $subject-ink; box-shadow: $shadow-press; } // 17.11:1
.ch__btn--quiet { background: rgba($subject-cream, .12); color: $subject-cream; } // 12.33:1
.ch__btn:hover,
.ch__btn:focus-visible { transform: translateY(-1px); }
.ch__btn--solid:hover,
.ch__btn--solid:focus-visible { box-shadow: $shadow-raised; }
.ch__btn:active { transform: translateY(0); }
.ch__hint {
margin: $sp-3 0 0; text-align: center;
font-weight: 600; font-size: clamp(8px, 2.4vw, 9px); line-height: 1;
color: rgba($subject-cream, .62); // 7.12:1
}
// ── 미리보기 루프: 포커스 → 빛 무리 한 겹 → 전송 → 원래대로 (복귀 90% 안)
.ch.is-demo .ch__field {
animation-name: chRing;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ch.is-demo .ch__ghost {
animation-name: chGhost;
animation-duration: $duration;
animation-timing-function: steps(1, end); // 글자 교체는 컷 — 반투명 유령 프레임을 만들지 않는다
animation-iteration-count: infinite;
}
.ch.is-demo .ch__send {
animation-name: chPress;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ch.is-demo .ch__dot {
animation-name: chDot;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes chRing {
0%, 6% { border-color: rgba($subject-cream, .18); box-shadow: 0 0 0 0 rgba($color, 0); }
30%, 66% { border-color: rgba($color, .62); box-shadow: 0 0 0 4px rgba($color, .34); }
86%, 100% { border-color: rgba($subject-cream, .18); box-shadow: 0 0 0 0 rgba($color, 0); }
}
@keyframes chGhost {
0% { opacity: 0; }
12% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes chPress {
0%, 62% { transform: translateY(0); }
66%, 74% { transform: translateY(1px); }
78%, 100% { transform: translateY(0); }
}
@keyframes chDot {
0%, 62% { transform: scale(1); opacity: .6; }
66%, 74% { transform: scale(1.55); opacity: 1; }
78%, 100% { transform: scale(1); opacity: .6; }
}
// ── 320×200 폰 실측 대비 압축 (BD9). 이 블록 안에서는 간격을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.ch__field { padding: $sp-1 $sp-1 $sp-1 $sp-3; }
.ch__lead { width: 14px; height: 14px; }
.ch__input, .ch__ghost { font-size: 10px; }
.ch__send { width: 26px; height: 26px; }
.ch__ico { width: 13px; height: 13px; }
.ch__row { margin-top: $sp-2; }
.ch__btn { padding: $sp-2; font-size: 10px; }
.ch__hint { margin-top: $sp-2; font-size: 7px; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임만 뺀다 — 포커스 링과 고스트 글자라는 피드백은 그대로 남긴다
.ch.is-demo .ch__field,
.ch.is-demo .ch__ghost,
.ch.is-demo .ch__send,
.ch.is-demo .ch__dot { animation: none !important; }
.ch.is-demo .ch__field { border-color: rgba($color, .62) !important; box-shadow: 0 0 0 4px rgba($color, .34) !important; }
.ch.is-demo .ch__ghost { opacity: 1 !important; }
.ch__field, .ch__send, .ch__dot, .ch__btn { transition: none !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);
}
.ch {
box-sizing: border-box;
width: min(100%, 400px);
}
.ch__field {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 8px 16px;
border: 1px solid rgba(255, 247, 230, 0.18);
border-radius: 999px;
background: rgba(255, 247, 230, 0.08);
box-shadow: 0 0 0 0 rgba(185, 160, 255, 0);
transition: box-shadow 300ms ease-out, border-color 300ms ease-out;
}
.ch__field:focus-within {
border-color: rgba(185, 160, 255, 0.62);
box-shadow: 0 0 0 4px rgba(185, 160, 255, 0.34);
}
.ch__lead {
display: inline-flex;
width: 16px;
height: 16px;
flex: 0 0 auto;
color: rgba(255, 247, 230, 0.62);
}
.ch__lead svg {
width: 100%;
height: 100%;
}
.ch__box {
position: relative;
display: block;
flex: 1 1 auto;
min-width: 0;
}
.ch__input {
display: block;
width: 100%;
border: 0;
background: none;
padding: 0;
font-family: inherit;
font-weight: 600;
font-size: clamp(10px, 2.9vw, 12px);
line-height: 1.6;
color: #fff7e6;
}
.ch__input:focus {
outline: none;
}
.ch__input::placeholder {
color: rgba(255, 247, 230, 0.62);
opacity: 1;
}
.ch__ghost {
position: absolute;
left: 0;
top: 0;
width: 100%;
opacity: 0;
font-weight: 600;
font-size: clamp(10px, 2.9vw, 12px);
line-height: 1.6;
color: #fff7e6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.ch.is-demo .ch__input::placeholder {
color: transparent;
}
.ch__send {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
flex: 0 0 auto;
border: 0;
border-radius: 50%;
cursor: pointer;
background: #fff7e6;
color: #17141a;
transition: transform 140ms ease-out;
}
.ch__ico {
display: inline-flex;
width: 15px;
height: 15px;
}
.ch__ico svg {
width: 100%;
height: 100%;
}
.ch__dot {
position: absolute;
right: 5px;
top: 5px;
width: 8px;
height: 8px;
border-radius: 50%;
opacity: 0.6;
background: radial-gradient(circle at 50% 50%, #6ec8ff 0%, #2f6df6 70%);
transform-origin: 50% 50%;
transition: transform 140ms ease-out, opacity 140ms ease-out;
}
.ch__send:active {
transform: translateY(1px);
}
.ch__send:active .ch__dot {
transform: scale(1.55);
opacity: 1;
}
.ch__send:focus-visible {
outline: 2px solid rgba(185, 160, 255, 0.8);
outline-offset: 2px;
}
.ch__row {
display: flex;
gap: 8px;
margin-top: 12px;
}
.ch__btn {
flex: 1 1 0;
padding: 12px 8px;
border: 0;
border-radius: 12px;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.9vw, 12px);
line-height: 1;
transition: transform 220ms ease-out, box-shadow 220ms ease-out;
}
.ch__btn--solid {
background: #fff7e6;
color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ch__btn--quiet {
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
}
.ch__btn:hover,
.ch__btn:focus-visible {
transform: translateY(-1px);
}
.ch__btn--solid:hover,
.ch__btn--solid:focus-visible {
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ch__btn:active {
transform: translateY(0);
}
.ch__hint {
margin: 12px 0 0;
text-align: center;
font-weight: 600;
font-size: clamp(8px, 2.4vw, 9px);
line-height: 1;
color: rgba(255, 247, 230, 0.62);
}
.ch.is-demo .ch__field {
animation-name: chRing;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.ch.is-demo .ch__ghost {
animation-name: chGhost;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ch.is-demo .ch__send {
animation-name: chPress;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.ch.is-demo .ch__dot {
animation-name: chDot;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes chRing {
0%, 6% {
border-color: rgba(255, 247, 230, 0.18);
box-shadow: 0 0 0 0 rgba(185, 160, 255, 0);
}
30%, 66% {
border-color: rgba(185, 160, 255, 0.62);
box-shadow: 0 0 0 4px rgba(185, 160, 255, 0.34);
}
86%, 100% {
border-color: rgba(255, 247, 230, 0.18);
box-shadow: 0 0 0 0 rgba(185, 160, 255, 0);
}
}
@keyframes chGhost {
0% {
opacity: 0;
}
12% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes chPress {
0%, 62% {
transform: translateY(0);
}
66%, 74% {
transform: translateY(1px);
}
78%, 100% {
transform: translateY(0);
}
}
@keyframes chDot {
0%, 62% {
transform: scale(1);
opacity: 0.6;
}
66%, 74% {
transform: scale(1.55);
opacity: 1;
}
78%, 100% {
transform: scale(1);
opacity: 0.6;
}
}
@media (max-width: 340px), (max-height: 220px) {
.ch__field {
padding: 4px 4px 4px 12px;
}
.ch__lead {
width: 14px;
height: 14px;
}
.ch__input, .ch__ghost {
font-size: 10px;
}
.ch__send {
width: 26px;
height: 26px;
}
.ch__ico {
width: 13px;
height: 13px;
}
.ch__row {
margin-top: 8px;
}
.ch__btn {
padding: 8px;
font-size: 10px;
}
.ch__hint {
margin-top: 8px;
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.ch.is-demo .ch__field,
.ch.is-demo .ch__ghost,
.ch.is-demo .ch__send,
.ch.is-demo .ch__dot {
animation: none !important;
}
.ch.is-demo .ch__field {
border-color: rgba(185, 160, 255, 0.62) !important;
box-shadow: 0 0 0 4px rgba(185, 160, 255, 0.34) !important;
}
.ch.is-demo .ch__ghost {
opacity: 1 !important;
}
.ch__field, .ch__send, .ch__dot, .ch__btn {
transition: none !important;
}
}// 사람이 손을 대면 미리보기 루프를 끄고 진짜 포커스·누름에 넘긴다.
// 포커스를 코드로 강제하지 않는다 — iframe 안에서 강제 포커스는 부모 페이지를 끌어당긴다 (BD18)
var chat = document.getElementById('ch');
var input = document.getElementById('chInput');
var send = document.getElementById('chSend');
function stopPreview() { chat.classList.remove('is-demo'); }
input.addEventListener('focus', stopPreview);
input.addEventListener('pointerdown', stopPreview);
send.addEventListener('pointerdown', stopPreview);
send.addEventListener('focus', stopPreview);import { useState } from "react";
import styles from "./ButtonsInputsOrb.module.scss";
export type ButtonsInputsOrbProps = {
/** 묻고 → 보내고 → 돌아오는 한 바퀴 */
duration?: string;
/** 빛 무리가 번지는 이징 */
easing?: string;
/** 빛 무리 색 — --ringColor 로 내려간다 */
color?: string;
};
type Loop = {
animationName: string;
animationDuration: string;
animationTimingFunction: string;
animationIterationCount: string;
};
export default function ButtonsInputsOrb({
duration = "2s",
easing = "ease-out",
color = "#b9a0ff",
}: ButtonsInputsOrbProps) {
// 사람이 손을 대면 미리보기 루프를 끈다. 포커스를 코드로 강제하지 않는다 —
// iframe 안에서 강제 포커스는 부모 페이지를 끌어당긴다 (BD18)
const [preview, setPreview] = useState(true);
const [text, setText] = useState("");
const stopPreview = () => { setPreview(false); };
const loop = (name: string, timing: string): Loop | undefined =>
preview ? {
animationName: name,
animationDuration: duration,
animationTimingFunction: timing,
animationIterationCount: "infinite",
} : undefined;
return (
<main className={styles.stage}>
<div className={`${styles.ch} ${preview ? styles.isDemo : ""}`} style={{ "--ringColor": color }}>
<div className={styles.field} style={loop("chRing", easing)}>
<span className={styles.lead} aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></span>
<span className={styles.box}>
<input
className={styles.input}
type="text"
placeholder="무엇이든 물어보세요"
aria-label="AI에게 물어볼 내용"
value={text}
onChange={(e: { target: { value: string } }) => { setText(e.target.value); }}
onFocus={stopPreview}
onPointerDown={stopPreview}
/>
{/* 미리보기용 고스트 글자. 보일 때는 placeholder 가 투명해진다 */}
<span className={styles.ghost} aria-hidden="true" style={loop("chGhost", "steps(1, end)")}>
이 랜딩의 전환율을 올릴 방법은?
</span>
</span>
<button
type="button"
className={styles.send}
aria-label="보내기"
style={loop("chPress", easing)}
onPointerDown={stopPreview}
onFocus={stopPreview}
>
<span className={styles.dot} aria-hidden="true" style={loop("chDot", easing)} />
<span className={styles.ico} aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M223.69,42.18l-58.22,192a8,8,0,0,1-14.92,1.25L108,148,20.58,105.45a8,8,0,0,1,1.25-14.92l192-58.22A8,8,0,0,1,223.69,42.18Z" opacity="0.2"/><path d="M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z"/></svg></span>
</button>
</div>
<div className={styles.row}>
<button type="button" className={`${styles.btn} ${styles.btnSolid}`} onClick={stopPreview}>답변 받기</button>
<button type="button" className={`${styles.btn} ${styles.btnQuiet}`} onClick={stopPreview}>예시 프롬프트</button>
</div>
<p className={styles.hint}>답변은 보통 2초 안에 옵니다</p>
</div>
</main>
);
}09 · Orb buttons & chat input — Measured area 4.223% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Motion here comes in short bursts, which is why this section registers movement in only 12 of 23 frame comparisons.
.ch__field {
box-shadow: 0 0 0 0 rgba($color, 0);
transition: box-shadow $dur-base $easing, border-color $dur-base $easing;
}
.ch__field:focus-within {
border-color: rgba($color, .62);
box-shadow: 0 0 0 4px rgba($color, .34);
}
.ch__dot {
position: absolute; right: 5px; top: 5px;
width: 8px; height: 8px; border-radius: 50%; opacity: .6;
background: radial-gradient(circle at 50% 50%, rgba($subject-sky, 1) 0%, rgba($subject-blue, 1) 70%);
transform-origin: 50% 50%;
transition: transform $dur-instant $easing, opacity $dur-instant $easing;
}
.ch__send:active .ch__dot { transform: scale(1.55); opacity: 1; }
Custom tailwind.config
The value worth centralizing here is not the palette but the shadows. A card rests at rest and deepens to raised as it lifts, and a focused input gets halo exactly once; those three lines are the whole depth system.
theme: {
extend: {
colors: {
ink: "#17141a", surface: "#252228", line: "#332f38",
cream: "#fff7e6", mute: "#beb7ad",
sky: "#6ec8ff", brand: "#2f6df6", lilac: "#b9a0ff",
flame: "#ff4d1f", paper: "#ffffff",
pink: "#ff9ebb", mint: "#4cd4a6",
},
borderRadius: { card: "16px", control: "12px", chip: "4px", pill: "999px" },
boxShadow: {
rest: "0 6px 12px rgba(23,20,26,.32)",
raised: "0 14px 24px rgba(23,20,26,.32)",
halo: "0 0 0 4px rgba(185,160,255,.34)",
},
spacing: { gutter: "16px" },
transitionTimingFunction: {
spring: "cubic-bezier(.2,.8,.2,1)",
pop: "cubic-bezier(.2,1.4,.4,1)",
},
},
},
| Role | Token | Where it belongs |
|---|---|---|
| Orb hue, sections 01–03 | sky #6ec8ff |
The top of the page, where attention is cheapest |
| Orb hue, sections 04–06 | brand #2f6df6 |
The middle, where the product is explained |
| Orb hue, sections 07–09 | lilac #b9a0ff |
The closing band, footer, and controls |
| Resting depth | shadow-rest |
Cards and solid buttons while idle |
| Lifted depth | shadow-raised |
Hover, focus, and the recommended pricing card |
| Focus halo | shadow-halo |
Inputs only, one layer |
A pure CSS version of the same values ships as tokens.css, so the kit works whether or not Tailwind is in the project.
Vibe-Coding Prompt
Hand this to Cursor or Claude Code with the config above already in the project. The order of the instructions matters: the rule about what may glow has to land before any color list, or the model will happily light the background too. The unabridged version travels as prompt.md inside the zip.
Build a landing page on one flat #17141a background. Nothing on the page glows except
the orbs: buttons are solid cream #fff7e6 with ink text and lift 1px on hover.
An orb is a circle of stacked radial-gradients — a white highlight at 30% 30%, a colored
body, and a wider halo circle behind it. Never a photo, never filter: blur.
Hue ladder: #6ec8ff for the first three sections, #2f6df6 for the middle three,
#b9a0ff for the last three. Give every orb aria-hidden="true" and say the active state,
the order and the recommended plan in text, borders or shadow as well.
Where it breaks (the trap)
The trap that cost the most time was the animation shorthand. Four of the nine sections stagger their orbs with animation-delay, and writing animation: name 2s ease infinite after the delay silently resets that delay to zero — every orb then wakes at the same instant and the stagger disappears. The fix is to spell the properties out one by one and add animation-fill-mode: backwards; otherwise each delayed element flashes its unanimated state for the length of its own delay: 360ms in the card grid, 1667ms on the last row of the feature list.
The second trap is a habit that every one of these sections tempts you into: building hierarchy by dimming text. Cream on the plain ink stage measures 17.11:1, and the four lines in the CTA band each cross the orb at a different height, so each has its own worst pixel: the headline passes over #53528a at 6.70:1, while the subtitle is the only line that meets the core head-on at #6e6096, where capping the core alpha at .55 keeps full-strength cream at 5.22:1. The subtitle was therefore the line that broke, because it had been set to 72 percent cream to sit quietly under the headline — that same pixel computes to 3.58:1, under the 4.5:1 floor, so the transparency came off and the subtitle is now full cream at 5.22:1 with its rank carried by size and weight instead (19px/800 against 11px/500). Over a light source you cannot dim type; opacity is a hierarchy tool that stops working the moment something glows underneath it. Type that changes width per language and breaks a coordinate calculation shows up the same way around button labels in transactional email templates. All nine sections, both vanilla and React, are packed in one archive that opens with e3bm95my, and the numbers above come from the same render that produced the preview loops.
Accessibility
Orbs carry no information, so every one of them is marked aria-hidden="true" and whatever they decorate is stated somewhere a screen reader can find: the nav marks its current link with aria-current, the pricing card announces itself with a border and a label, and the feature list reads in document order whether or not anything is lit. Contrast was computed from the demo colors with the relative luminance formula in the WCAG 2.1 contrast minimum, not eyeballed.
| Pairing | Ratio | Verdict |
|---|---|---|
| Cream #fff7e6 on ink #17141a | 17.11:1 | pass |
| Sky #6ec8ff eyebrow on ink | 9.87:1 | pass |
| Muted body, cream at 72% over ink (#beb7ad) | 9.18:1 | pass |
| Card title, cream on ink tinted 6% sky (#1c1f28) | 15.44:1 | pass |
| Pricing text, ink on white | 18.24:1 | pass |
| Pricing subline, ink at 62% on white (#6f6d71) | 5.12:1 | pass |
| Chat placeholder, cream at 62% on the field (#2a262a) | 6.32:1 | pass |
| CTA headline, cream over its own worst pixel (#53528a) | 6.70:1 | pass |
| CTA subtitle, full cream over the orb core (#6e6096) | 5.22:1 | pass |
| The same line dimmed to 72% cream | 3.58:1 | fails — which is why nothing over an orb is dimmed |
| Brand blue #2f6df6 on ink | 4.03:1 | decorative icons only, never text |
Under prefers-reduced-motion each section keeps its meaning and drops its movement. The hero orb stands still at 90 percent opacity, the nav dot parks on the current tab, all six feature rows sit fully lit instead of taking turns, the pricing badge holds its place above the recommended plan, and the footer beacons freeze at the 20, 50, and 62 percent marks so the rule still reads as a track. The chat field is the interesting one: its animation stops, but the focus ring and the typed-in text are forced on, because those are feedback rather than decoration. Since every orb is a radial-gradient rather than an image, a browser that cannot draw one falls back to flat color and the layout survives.
If soft light on a pale surface is closer to what you are after, the neumorphism kit solves the same nine slots with double shadows, and the trustworthy SaaS layout does it in daylight. More of these live in the design category.
FAQ
Why not just put a gradient on the background of an ai saas landing page design?
Because the glow has to come from somewhere specific. A full-bleed aurora lights every pixel equally, and once the background glows the orbs read as smudges rather than objects. Flattening the page to one solid color is what buys a single light source its meaning. To keep the pricing cells in 05 working with a live toggle and real arithmetic, that part lives in pricing table interactions.
How many orbs can one page carry?
Roughly one per section, with one that is clearly the biggest. Across the nine measured sections here the total moved area is 81.469 percent of a frame, and 33.242 of that belongs to the CTA band alone — the imbalance is deliberate, since a page where every section moves the same amount has no climax.
Does the kit work without Tailwind?
Yes. The archive carries tokens.css with the same colors, radii, shadows, and gradient recipes as CSS custom properties, plus React versions of all nine sections with .tsx and .module.scss files. The SCSS sources compile on their own, so nothing in the kit depends on a build step you do not already have.









