Linear Style Landing Page: 9 Dark SaaS Sections
A linear style landing page — all over Dribbble and Pinterest right now — leans on Linear.app's ink-black background and one blue glow instead of
Scroll the assembled one-page mock below before copying anything on its own. The thing to watch for is whether the brightness ever wavers between the top nav and the footer.

The nine pieces below are cut from that same page, so each one carries the linear style ui design rules intact. They run in the order a visitor actually meets them — first glance, browsing the grid, checking the price, deciding whether to trust it, and then acting on it. Split the same dark mood into tiles and it becomes the bento grid landing sections; let the type itself move to carry the mood and the kinetic typography landing picks it up.
01 hero-linear
02 nav-linear
03 card-grid-linear
04 feature-list-linear
05 pricing-linear
06 testimonial-linear
07 cta-band-linear
08 footer-linear
09 buttons-inputs-linear
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Product mockup + glow hero
- 02 Blurred sticky nav
- 03 Border-highlight cards
- 04 Monospace label list
- 05 Glowing highlighted plan
- 06 Logo wall + quote
- 07 Gradient-border band
- 08 4-column dark footer
- 09 Ghost/primary buttons, dark inputs
01Hero
A mockup on the ink background
A browser-chrome mockup window floats on the ink background, and behind it a single soft blue glow bleeds outward with nothing else competing for attention.

<main class="stage">
<section class="mock is-demo">
<span class="mock__glow" aria-hidden="true"></span>
<div class="mock__bar">
<span class="mock__dot"></span>
<span class="mock__dot"></span>
<span class="mock__dot"></span>
</div>
<div class="mock__body">
<p class="mock__eyebrow">Console</p>
<h2 class="mock__title">From one screen to deploy</h2>
<button class="mock__cta" type="button">Start for free</button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 목업이 뜨고 가라앉는 한 주기
$easing: $ease-spring; // 등장 곡선
$color: $subject-blue; // 글로우 색
@include stage($stage-ink);
.mock {
position: relative;
width: clamp(220px, 78vw, 340px);
border-radius: $r-card;
background: $subject-ink;
border: 1px solid rgba($subject-cream, .08);
box-shadow: $shadow-raised;
overflow: hidden;
}
.mock__glow {
position: absolute;
left: 50%;
top: -20%;
width: 70%;
height: 140%;
transform: translateX(-50%);
background: radial-gradient(circle, rgba($color, .55) 0%, rgba($color, 0) 70%);
filter: blur(6px);
animation: glowPulse $duration $easing infinite;
pointer-events: none;
}
.mock__bar {
position: relative;
display: flex;
gap: $sp-2;
padding: clamp(8px, 2.4vw, $sp-3) clamp(12px, 3.2vw, $sp-4);
border-bottom: 1px solid rgba($subject-cream, .08);
}
.mock__dot {
width: $sp-2;
height: $sp-2;
border-radius: 50%;
background: rgba($subject-cream, .18);
}
.mock__body {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: clamp(8px, 2.4vw, $sp-3);
padding: clamp(16px, 6vw, $sp-8) clamp(12px, 4vw, $sp-6) clamp(20px, 7vw, $sp-10);
animation: bodyRise $duration $easing infinite;
}
.mock__eyebrow {
margin: 0;
color: $subject-blue-light; // 먹 면 위 파란 글자 — AA 4.5 (blog-design 글자 대비)
font: 700 clamp(10px, 2.6vw, 12px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
letter-spacing: .06em;
text-transform: uppercase;
}
.mock__title {
margin: 0;
color: $subject-cream;
font: 700 clamp(15px, 4.4vw, 22px)/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.mock__cta {
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
padding: $sp-2 $sp-5;
font-weight: 600; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
}
@keyframes glowPulse {
0%, 100% { opacity: .45; transform: translateX(-50%) scale(.94); }
50% { opacity: .85; transform: translateX(-50%) scale(1.04); }
}
@keyframes bodyRise {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
.mock__glow, .mock__body { animation: none !important; opacity: .65; transform: none; }
}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);
}
.mock {
position: relative;
width: clamp(220px, 78vw, 340px);
border-radius: 16px;
background: #17141a;
border: 1px solid rgba(255, 247, 230, 0.08);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.mock__glow {
position: absolute;
left: 50%;
top: -20%;
width: 70%;
height: 140%;
transform: translateX(-50%);
background: radial-gradient(circle, rgba(47, 109, 246, 0.55) 0%, rgba(47, 109, 246, 0) 70%);
filter: blur(6px);
animation: glowPulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
pointer-events: none;
}
.mock__bar {
position: relative;
display: flex;
gap: 8px;
padding: clamp(8px, 2.4vw, 12px) clamp(12px, 3.2vw, 16px);
border-bottom: 1px solid rgba(255, 247, 230, 0.08);
}
.mock__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.18);
}
.mock__body {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: clamp(8px, 2.4vw, 12px);
padding: clamp(16px, 6vw, 32px) clamp(12px, 4vw, 24px) clamp(20px, 7vw, 40px);
animation: bodyRise 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.mock__eyebrow {
margin: 0;
color: #5b8cf8;
font: 700 clamp(10px, 2.6vw, 12px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.mock__title {
margin: 0;
color: #fff7e6;
font: 700 clamp(15px, 4.4vw, 22px)/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.mock__cta {
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
padding: 8px 20px;
font-weight: 600;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
}
@keyframes glowPulse {
0%, 100% {
opacity: 0.45;
transform: translateX(-50%) scale(0.94);
}
50% {
opacity: 0.85;
transform: translateX(-50%) scale(1.04);
}
}
@keyframes bodyRise {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
@media (prefers-reduced-motion: reduce) {
.mock__glow, .mock__body {
animation: none !important;
opacity: 0.65;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./HeroLinear.module.scss";
export type HeroLinearProps = {
eyebrow?: string;
title?: string;
cta?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function HeroLinear({
eyebrow = "Console",
title = "From one screen to deploy",
cta = "Start for free",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: HeroLinearProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.mock} style={vars}>
<span className={styles.glow} aria-hidden="true" />
<div className={styles.bar}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</div>
<div className={styles.body}>
<p className={styles.eyebrow}>{eyebrow}</p>
<h2 className={styles.title}>{title}</h2>
<button type="button" className={styles.cta}>{cta}</button>
</div>
</section>
);
}01 · Product mockup + glow hero — Measured area 24.265% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why the eye goes inward
The window's title and its CTA button rise and settle together on a slow loop, which pulls the eye toward the mock instead of the page around it — no JavaScript runs any of it; the whole float is a CSS keyframe loop that starts the instant the page paints.
<section class="relative overflow-hidden rounded-card border border-line bg-ink p-2 shadow-raised">
<span class="glow pointer-events-none absolute left-1/2 top-0 h-96 w-96
-translate-x-1/2"></span>
<div class="flex gap-1.5 px-3 py-2">
<span class="h-2.5 w-2.5 rounded-full bg-cream/20"></span>
<span class="h-2.5 w-2.5 rounded-full bg-cream/20"></span>
</div>
<div class="relative flex flex-col gap-3 px-6 pb-10 pt-8">
<p class="text-xs font-bold uppercase tracking-wide text-accent">Console</p>
<h2 class="text-2xl font-bold text-cream">Ship from one screen</h2>
<button class="w-fit rounded-pill bg-accent px-5 py-2 text-sm font-bold text-cream">
Start free
</button>
</div>
</section>
02Navigation
A pill that glides
Inside a nav bar blurred with backdrop-filter, a small pill glides between menu items to mark whichever one is active, while the brand mark and the CTA button stay fixed at either end of the capsule.

<main class="stage">
<nav class="navbar">
<span class="navbar__brand">Vantage</span>
<div class="navbar__items is-demo">
<span class="navbar__pill" aria-hidden="true"></span>
<span class="navbar__item">Home</span>
<span class="navbar__item">Features</span>
<span class="navbar__item">Pricing</span>
</div>
<button class="navbar__cta" type="button">Start</button>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 필이 두 항목 사이를 오가는 한 주기
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
$item-gap: $sp-1;
.navbar {
--item-w: clamp(32px, 12vw, 64px); // 3개 항목이 320px 폭에서도 브랜드·CTA 와 함께 들어가도록 반응형
display: flex;
align-items: center;
gap: $sp-4;
width: clamp(240px, 84vw, 380px);
border-radius: $r-pill;
background: rgba($subject-ink, .92);
border: 1px solid rgba($subject-cream, .1);
box-shadow: $shadow-raised;
padding: $sp-2 $sp-2 $sp-2 $sp-4;
backdrop-filter: blur(10px);
}
.navbar__brand {
flex: 0 0 auto;
color: $subject-cream;
font: 700 clamp(12px, 3vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.navbar__items {
position: relative;
display: flex;
gap: $item-gap;
flex: 1 1 auto;
background: rgba($subject-cream, .05);
border-radius: $r-pill;
padding: $sp-1;
}
.navbar__pill {
position: absolute;
left: $sp-1;
top: $sp-1;
bottom: $sp-1;
width: var(--item-w);
border-radius: $r-pill;
background: $subject-blue-deep;
animation: pillMove $duration $easing infinite;
}
.navbar__item {
position: relative;
z-index: 1;
flex: 0 0 var(--item-w);
width: var(--item-w);
text-align: center;
padding: $sp-2 0;
color: rgba($subject-cream, .9); // 파란 알약($subject-blue-deep) 위에서 .7 은 3.68:1 이었다 — AA 4.5 (5.10:1)
font-weight: 600; font-size: clamp(10.5px, 2.6vw, 12px); line-height: 1;
}
.navbar__cta {
flex: 0 0 auto;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
padding: $sp-2 $sp-4;
font-weight: 600; font-size: clamp(10.5px, 2.6vw, 12px); line-height: 1;
}
@keyframes pillMove {
0%, 20% { transform: translateX(0); }
50%, 70% { transform: translateX(calc(var(--item-w) + #{$item-gap})); }
100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.navbar__pill { animation: none !important; transform: translateX(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.navbar {
--item-w: clamp(32px, 12vw, 64px);
display: flex;
align-items: center;
gap: 16px;
width: clamp(240px, 84vw, 380px);
border-radius: 999px;
background: rgba(23, 20, 26, 0.92);
border: 1px solid rgba(255, 247, 230, 0.1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 8px 8px 8px 16px;
backdrop-filter: blur(10px);
}
.navbar__brand {
flex: 0 0 auto;
color: #fff7e6;
font: 700 clamp(12px, 3vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.navbar__items {
position: relative;
display: flex;
gap: 4px;
flex: 1 1 auto;
background: rgba(255, 247, 230, 0.05);
border-radius: 999px;
padding: 4px;
}
.navbar__pill {
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
width: var(--item-w);
border-radius: 999px;
background: #0a4fe8;
animation: pillMove 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.navbar__item {
position: relative;
z-index: 1;
flex: 0 0 var(--item-w);
width: var(--item-w);
text-align: center;
padding: 8px 0;
color: rgba(255, 247, 230, 0.9);
font-weight: 600;
font-size: clamp(10.5px, 2.6vw, 12px);
line-height: 1;
}
.navbar__cta {
flex: 0 0 auto;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
padding: 8px 16px;
font-weight: 600;
font-size: clamp(10.5px, 2.6vw, 12px);
line-height: 1;
}
@keyframes pillMove {
0%, 20% {
transform: translateX(0);
}
50%, 70% {
transform: translateX(calc(var(--item-w) + 4px));
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.navbar__pill {
animation: none !important;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./NavLinear.module.scss";
export type NavLinearProps = {
brand?: string;
items?: [string, string, string];
cta?: string;
/** 필이 두 항목 사이를 오가는 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function NavLinear({
brand = "Vantage",
items = ["홈", "기능", "요금제"],
cta = "시작",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#0a4fe8",
}: NavLinearProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<nav className={styles.navbar} style={vars}>
<span className={styles.brand}>{brand}</span>
<div className={styles.items}>
<span className={styles.pill} aria-hidden="true" />
{items.map((label) => (
<span key={label} className={styles.item}>{label}</span>
))}
</div>
<button type="button" className={styles.cta}>{cta}</button>
</nav>
);
}02 · Blurred sticky nav — Measured area 2.219% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it never drifts
Because each item is a fixed 64px flex child rather than one sized by its own text, the pill never drifts off target as it slides.
<nav class="flex items-center gap-4 rounded-pill bg-ink/90 border border-line
backdrop-blur px-3 py-2 shadow-raised">
<span class="pl-3 font-display font-extrabold text-sm text-cream">Vantage</span>
<div class="relative flex flex-1 rounded-pill bg-cream/5 p-1">
<span class="absolute inset-y-1 left-1 w-1/4 rounded-pill bg-accent"></span>
<span class="relative z-10 flex-1 text-center text-xs font-semibold py-2 text-cream">Home</span>
<span class="relative z-10 flex-1 text-center text-xs py-2 text-cream/70">Features</span>
</div>
<button class="rounded-pill bg-accent px-4 py-2 text-xs font-bold text-cream">Sign in</button>
</nav>
03Card Grid
Borders that hand off in turn
Across a dark card grid, each tile's border lights up blue in turn, fades, and hands off to the next — each card's animation-delay is offset by a quarter of the loop so the sequence reads left to right instead of flashing all at once.

<main class="stage">
<section class="grid is-demo">
<article class="grid__card grid__card--1">
<span class="grid__dot" aria-hidden="true"></span>
<p class="grid__label">Auto deploy</p>
</article>
<article class="grid__card grid__card--2">
<span class="grid__dot" aria-hidden="true"></span>
<p class="grid__label">Issue tracking</p>
</article>
<article class="grid__card grid__card--3">
<span class="grid__dot" aria-hidden="true"></span>
<p class="grid__label">Roadmap</p>
</article>
<article class="grid__card grid__card--4">
<span class="grid__dot" aria-hidden="true"></span>
<p class="grid__label">API integrations</p>
</article>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 카드 한 장이 밝아졌다 가라앉는 한 주기
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-orange);
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-3;
width: clamp(220px, 76vw, 320px);
}
.grid__card {
border-radius: $r-card;
background: $subject-ink;
border: 1px solid rgba($subject-cream, .08);
padding: $sp-4;
display: flex;
flex-direction: column;
gap: $sp-4;
animation-name: cardGlow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.grid__card--1 { animation-delay: 0s; }
.grid__card--2 { animation-delay: $duration * -0.75; }
.grid__card--3 { animation-delay: $duration * -0.5; }
.grid__card--4 { animation-delay: $duration * -0.25; }
.grid__dot {
width: $sp-4;
height: $sp-4;
border-radius: $r-xs;
background: rgba($color, .35);
}
.grid__label {
margin: 0;
color: $subject-cream;
font: 600 clamp(11px, 2.8vw, 13px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes cardGlow {
0%, 76%, 100% { border-color: rgba($subject-cream, .08); box-shadow: none; }
13% { border-color: $color; box-shadow: 0 0 0 1px rgba($color, .5), $shadow-raised; }
}
@media (prefers-reduced-motion: reduce) {
.grid__card { animation: none !important; border-color: rgba($subject-cream, .08); box-shadow: none; }
}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);
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
width: clamp(220px, 76vw, 320px);
}
.grid__card {
border-radius: 16px;
background: #17141a;
border: 1px solid rgba(255, 247, 230, 0.08);
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
animation-name: cardGlow;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.grid__card--1 {
animation-delay: 0s;
}
.grid__card--2 {
animation-delay: -1.5s;
}
.grid__card--3 {
animation-delay: -1s;
}
.grid__card--4 {
animation-delay: -0.5s;
}
.grid__dot {
width: 16px;
height: 16px;
border-radius: 4px;
background: rgba(47, 109, 246, 0.35);
}
.grid__label {
margin: 0;
color: #fff7e6;
font: 600 clamp(11px, 2.8vw, 13px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes cardGlow {
0%, 76%, 100% {
border-color: rgba(255, 247, 230, 0.08);
box-shadow: none;
}
13% {
border-color: #2f6df6;
box-shadow: 0 0 0 1px rgba(47, 109, 246, 0.5), 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.grid__card {
animation: none !important;
border-color: rgba(255, 247, 230, 0.08);
box-shadow: none;
}
}import type { CSSProperties } from "react";
import styles from "./CardGridLinear.module.scss";
export type CardGridLinearProps = {
labels?: [string, string, string, string];
/** 카드 한 장이 밝아졌다 가라앉는 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CardGridLinear({
labels = ["자동 배포", "이슈 추적", "로드맵", "API 연동"],
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: CardGridLinearProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const delays = ["0s", `${-duration * 0.75}ms`, `${-duration * 0.5}ms`, `${-duration * 0.25}ms`];
return (
<section className={styles.grid} style={vars}>
{labels.map((label, i) => (
<article key={label} className={styles.card} style={{ animationDelay: delays[i] }}>
<span className={styles.dot} aria-hidden="true" />
<p className={styles.label}>{label}</p>
</article>
))}
</section>
);
}03 · Border-highlight cards — Measured area 12.578% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a flat grid falls short
Reach for it on a feature-showcase grid or a side-by-side comparison, anywhere a flat, static grid would let every tile compete equally for the same glance.
<div class="grid grid-cols-2 gap-gutter">
<div class="rounded-card border border-line bg-cream/[.03] p-6
transition-colors hover:border-accent">
<span class="mb-3 block h-4 w-4 rounded-sm bg-accent/40"></span>
<p class="text-sm font-semibold text-cream">Auto deploys</p>
</div>
<div class="rounded-card border border-line bg-cream/[.03] p-6
transition-colors hover:border-accent">
<span class="mb-3 block h-4 w-4 rounded-sm bg-accent/40"></span>
<p class="text-sm font-semibold text-cream">Issue tracking</p>
</div>
</div>
04Feature List
Monospace numbers out front
Each row carries a monospace number out front, and the rows themselves lift and glow one after another rather than highlighting all at once.

<main class="stage">
<ul class="list is-demo">
<li class="list__row list__row--1"><span class="list__num">01</span><span class="list__text">Real-time collaborative editing</span></li>
<li class="list__row list__row--2"><span class="list__num">02</span><span class="list__text">Keyboard-only command bar</span></li>
<li class="list__row list__row--3"><span class="list__num">03</span><span class="list__text">Per-version changelog</span></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 줄 하나가 스치듯 나타났다 사라지는 한 주기
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.list {
list-style: none;
margin: 0;
padding: $sp-4;
display: flex;
flex-direction: column;
gap: $sp-2;
width: clamp(220px, 76vw, 320px);
background: $subject-ink;
border-radius: $r-card;
box-shadow: $shadow-raised;
}
.list__row {
display: flex;
align-items: center;
gap: $sp-4;
padding: $sp-3 $sp-4;
border-radius: $r-sm;
// 데모 루프는 .is-demo 안에서만 — 정적(실사용) 상태는 opacity 1 이라야 글자 대비가 산다
.list.is-demo & { animation-name: rowSweep; }
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.list__row--1 { animation-delay: 0s; }
.list__row--2 { animation-delay: $duration * -0.67; }
.list__row--3 { animation-delay: $duration * -0.34; }
.list__num {
font: 700 clamp(11px, 2.8vw, 13px)/1 "Courier New", ui-monospace, monospace;
color: $subject-blue-light; // 먹 면 위 파란 글자 — AA 4.5 (blog-design 글자 대비)
}
.list__text {
color: $subject-cream;
font: 600 clamp(11px, 2.8vw, 13px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes rowSweep {
0%, 62%, 100% { opacity: .55; background: transparent; transform: translateY(0); }
17% { opacity: 1; background: rgba($color, .12); transform: translateY(-2px); }
}
@media (prefers-reduced-motion: reduce) {
.list__row { animation: none !important; opacity: .85; background: transparent; transform: none; }
}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);
}
.list {
list-style: none;
margin: 0;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
width: clamp(220px, 76vw, 320px);
background: #17141a;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.list__row {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
border-radius: 8px;
}
.list.is-demo .list__row {
animation-name: rowSweep;
}
.list__row {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.list__row--1 {
animation-delay: 0s;
}
.list__row--2 {
animation-delay: -1.34s;
}
.list__row--3 {
animation-delay: -0.68s;
}
.list__num {
font: 700 clamp(11px, 2.8vw, 13px)/1 "Courier New", ui-monospace, monospace;
color: #5b8cf8;
}
.list__text {
color: #fff7e6;
font: 600 clamp(11px, 2.8vw, 13px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes rowSweep {
0%, 62%, 100% {
opacity: 0.55;
background: transparent;
transform: translateY(0);
}
17% {
opacity: 1;
background: rgba(47, 109, 246, 0.12);
transform: translateY(-2px);
}
}
@media (prefers-reduced-motion: reduce) {
.list__row {
animation: none !important;
opacity: 0.85;
background: transparent;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./FeatureListLinear.module.scss";
export type FeatureListLinearProps = {
rows?: [string, string, string];
/** 줄 하나가 스치듯 나타났다 사라지는 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FeatureListLinear({
rows = ["실시간 협업 편집", "키보드 전용 커맨드바", "버전별 변경 로그"],
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: FeatureListLinearProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const delays = ["0s", `${-duration * 0.67}ms`, `${-duration * 0.34}ms`];
return (
<ul className={styles.list} style={vars}>
{rows.map((text, i) => (
<li key={text} className={styles.row} style={{ animationDelay: delays[i] }}>
<span className={styles.num}>{String(i + 1).padStart(2, "0")}</span>
<span className={styles.text}>{text}</span>
</li>
))}
</ul>
);
}04 · Monospace label list — Measured area 27.006% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it reads like a code editor
The numbering reads like line numbers in a code editor, which is exactly the association that makes this list feel like it belongs on a developer-tool page rather than a generic marketing site.
<ul class="flex flex-col gap-2 rounded-card border border-line bg-cream/[.03] p-4">
<li class="flex items-center gap-4 rounded-control px-4 py-3 hover:bg-accent/10">
<span class="font-mono text-xs font-bold text-accent">01</span>
<span class="text-sm font-semibold text-cream">Real-time collaborative editing</span>
</li>
<li class="flex items-center gap-4 rounded-control px-4 py-3 hover:bg-accent/10">
<span class="font-mono text-xs font-bold text-accent">02</span>
<span class="text-sm font-semibold text-cream">Keyboard-only command bar</span>
</li>
</ul>
05Pricing
One card breathes
Only the recommended plan's card wears a glowing blue border, and a soft blue halo breathes around it — expanding and fading on a slow pulse rather than sitting still.

<main class="stage">
<section class="plan is-demo">
<span class="plan__badge">Recommended</span>
<p class="plan__name">Pro</p>
<p class="plan__price">$19/mo</p>
<button class="plan__cta" type="button">Choose plan</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 글로우 테두리가 숨 쉬는 한 주기
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.plan {
position: relative;
width: clamp(180px, 60vw, 220px);
border-radius: $r-card;
background: $subject-ink;
border: 2px solid $color;
padding: $sp-8 $sp-6 $sp-6;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-2;
animation: planBreathe $duration $easing infinite;
}
.plan__badge {
position: absolute;
top: $sp-1 * -2;
left: $sp-6;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
padding: $sp-1 $sp-3;
font: 700 clamp(9.5px, 2.4vw, 11px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.plan__name {
margin: 0;
color: $subject-cream;
font: 700 clamp(14px, 3.6vw, 17px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.plan__price {
margin: 0;
color: rgba($subject-cream, .7);
font-weight: 600; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
}
.plan__cta {
margin-top: $sp-2;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
padding: $sp-2 $sp-5;
font-weight: 600; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
}
@keyframes planBreathe {
0%, 100% { box-shadow: 0 0 0 0 rgba($color, .35), $shadow-raised; transform: scale(1); }
50% { box-shadow: 0 0 0 10px rgba($color, 0), $shadow-raised; transform: scale(1.015); }
}
@media (prefers-reduced-motion: reduce) {
.plan { animation: none !important; box-shadow: 0 0 0 4px rgba($color, .2), $shadow-raised; transform: none; }
}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);
}
.plan {
position: relative;
width: clamp(180px, 60vw, 220px);
border-radius: 16px;
background: #17141a;
border: 2px solid #2f6df6;
padding: 32px 24px 24px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
animation: planBreathe 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.plan__badge {
position: absolute;
top: -8px;
left: 24px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
padding: 4px 12px;
font: 700 clamp(9.5px, 2.4vw, 11px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.plan__name {
margin: 0;
color: #fff7e6;
font: 700 clamp(14px, 3.6vw, 17px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.plan__price {
margin: 0;
color: rgba(255, 247, 230, 0.7);
font-weight: 600;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
}
.plan__cta {
margin-top: 8px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
padding: 8px 20px;
font-weight: 600;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
}
@keyframes planBreathe {
0%, 100% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.35), 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scale(1);
}
50% {
box-shadow: 0 0 0 10px rgba(47, 109, 246, 0), 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scale(1.015);
}
}
@media (prefers-reduced-motion: reduce) {
.plan {
animation: none !important;
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.2), 0 14px 24px rgba(23, 20, 26, 0.32);
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./PricingLinear.module.scss";
export type PricingLinearProps = {
badge?: string;
name?: string;
price?: string;
cta?: string;
/** 글로우 테두리가 숨 쉬는 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PricingLinear({
badge = "추천",
name = "프로",
price = "$19/mo",
cta = "선택하기",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: PricingLinearProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.plan} style={vars}>
<span className={styles.badge}>{badge}</span>
<p className={styles.name}>{name}</p>
<p className={styles.price}>{price}</p>
<button type="button" className={styles.cta}>{cta}</button>
</section>
);
}05 · Glowing highlighted plan — Measured area 6.678% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
No comparison table needed
It flags the plan you're steering people toward without needing a comparison table underneath it; a badge and one breathing glow do the whole job.
<div class="relative flex-[1.1] rounded-card border-2 border-accent
bg-cream/[.03] p-6 pt-8 shadow-glow">
<span class="absolute -top-3 left-6 rounded-pill bg-accent px-3 py-1
text-xs font-bold text-cream">Recommended</span>
<p class="font-bold text-cream">Pro</p>
<p class="mt-1 text-sm text-cream/60">$19 / month</p>
</div>
06Testimonials
Dimmed logos under a quote
Below a row of dimmed partner logos sits a single quote card, and the logos themselves brighten and dim in turn rather than sitting at full strength the whole time.

<main class="stage">
<section class="panel">
<div class="panel__logos is-demo" aria-hidden="true">
<span class="panel__logo panel__logo--1"></span>
<span class="panel__logo panel__logo--2"></span>
<span class="panel__logo panel__logo--3"></span>
<span class="panel__logo panel__logo--4"></span>
</div>
<blockquote class="panel__quote">
<p>“Our whole team switched over in a day, no onboarding docs needed.”</p>
<cite>Lee Ha-neul · Design Lead</cite>
</blockquote>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 로고 한 개가 밝아졌다 옅어지는 한 주기
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
.panel {
width: clamp(230px, 80vw, 340px);
border-radius: $r-card;
background: $subject-ink;
border: 1px solid rgba($subject-cream, .08);
box-shadow: $shadow-raised;
padding: $sp-6;
display: flex;
flex-direction: column;
gap: $sp-5;
}
.panel__logos {
display: flex;
justify-content: space-between;
gap: $sp-3;
}
.panel__logo {
flex: 1 1 0;
height: $sp-4;
border-radius: $r-xs;
background: rgba($subject-cream, .16);
animation-name: logoShine;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.panel__logo--1 { animation-delay: 0s; }
.panel__logo--2 { animation-delay: $duration * -0.75; }
.panel__logo--3 { animation-delay: $duration * -0.5; }
.panel__logo--4 { animation-delay: $duration * -0.25; }
.panel__quote {
margin: 0;
padding-top: $sp-4;
border-top: 1px solid rgba($subject-cream, .08);
}
.panel__quote p {
margin: 0 0 $sp-2;
color: $subject-cream;
font: 600 clamp(12px, 3vw, 14px)/1.5 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.panel__quote cite {
color: rgba($subject-cream, .55);
font-weight: 600; font-size: clamp(10.5px, 2.6vw, 12px); line-height: 1;
font-style: normal;
}
@keyframes logoShine {
0%, 76%, 100% { background: rgba($subject-cream, .16); }
13% { background: $color; }
}
@media (prefers-reduced-motion: reduce) {
.panel__logo { animation: none !important; background: rgba($subject-cream, .16); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
width: clamp(230px, 80vw, 340px);
border-radius: 16px;
background: #17141a;
border: 1px solid rgba(255, 247, 230, 0.08);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.panel__logos {
display: flex;
justify-content: space-between;
gap: 12px;
}
.panel__logo {
flex: 1 1 0;
height: 16px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.16);
animation-name: logoShine;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.panel__logo--1 {
animation-delay: 0s;
}
.panel__logo--2 {
animation-delay: -1.5s;
}
.panel__logo--3 {
animation-delay: -1s;
}
.panel__logo--4 {
animation-delay: -0.5s;
}
.panel__quote {
margin: 0;
padding-top: 16px;
border-top: 1px solid rgba(255, 247, 230, 0.08);
}
.panel__quote p {
margin: 0 0 8px;
color: #fff7e6;
font: 600 clamp(12px, 3vw, 14px)/1.5 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.panel__quote cite {
color: rgba(255, 247, 230, 0.55);
font-weight: 600;
font-size: clamp(10.5px, 2.6vw, 12px);
line-height: 1;
font-style: normal;
}
@keyframes logoShine {
0%, 76%, 100% {
background: rgba(255, 247, 230, 0.16);
}
13% {
background: #2f6df6;
}
}
@media (prefers-reduced-motion: reduce) {
.panel__logo {
animation: none !important;
background: rgba(255, 247, 230, 0.16);
}
}import type { CSSProperties } from "react";
import styles from "./TestimonialLinear.module.scss";
export type TestimonialLinearProps = {
quote?: string;
cite?: string;
logoCount?: number;
/** 로고 한 개가 밝아졌다 옅어지는 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TestimonialLinear({
quote = "Our whole team switched over in a day, no onboarding docs needed.",
cite = "Lee Ha-neul · Design Lead",
logoCount = 4,
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: TestimonialLinearProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const delays = Array.from({ length: logoCount }, (_, i) => `${-duration * (0.75 - i * 0.25)}ms`);
return (
<section className={styles.panel} style={vars}>
<div className={styles.logos} aria-hidden="true">
{delays.map((delay, i) => (
<span key={i} className={styles.logo} style={{ animationDelay: i === 0 ? "0s" : delay }} />
))}
</div>
<blockquote className={styles.quote}>
<p>“{quote}”</p>
<cite>{cite}</cite>
</blockquote>
</section>
);
}06 · Logo wall + quote — Measured area 3.367% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why 16% opacity
Full-color logos would read as an ad strip, so they're dropped to 16% opacity, letting the animation, not the color, carry the trust signal.
<div class="rounded-card border border-line bg-cream/[.03] p-6">
<div class="mb-5 flex justify-between gap-3 opacity-60">
<span class="h-4 flex-1 rounded-sm bg-cream/20"></span>
<span class="h-4 flex-1 rounded-sm bg-cream/20"></span>
</div>
<blockquote class="border-t border-line pt-4">
<p class="text-sm font-semibold text-cream">
“We moved the whole team over in a day, no onboarding doc needed.”
</p>
</blockquote>
</div>
07CTA Band
A rotating gradient edge
A gradient line wraps the CTA band's border and rotates slowly, so the edge reads like a glowing badge rather than a plain rule.

<main class="stage">
<section class="band">
<div class="band__inner is-demo">
<h3 class="band__title">Move your team over today</h3>
<button class="band__cta" type="button">Start for free</button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 테두리 그라디언트가 한 바퀴 도는 시간
$easing: linear; // 회전은 일정 속도가 자연스럽다 (spring·pop 은 회전에 안 맞는다)
$color: $subject-blue;
@include stage($stage-orange);
.band {
position: relative;
width: clamp(240px, 84vw, 360px);
border-radius: $r-card;
padding: $sp-1;
overflow: hidden;
isolation: isolate;
}
.band::before {
content: "";
position: absolute;
inset: -40%;
background: conic-gradient(from 0deg, transparent 0%, rgba($color, .95) 18%, transparent 40%);
animation: bandSpin $duration $easing infinite;
}
.band__inner {
position: relative;
z-index: 1;
border-radius: calc(#{$r-card} - $sp-1);
background: $subject-ink;
padding: $sp-8 $sp-6;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-4;
}
.band__title {
margin: 0;
color: $subject-cream;
font: 700 clamp(15px, 3.8vw, 19px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.band__cta {
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
padding: $sp-2 $sp-5;
font-weight: 600; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
}
@keyframes bandSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.band::before { 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.band {
position: relative;
width: clamp(240px, 84vw, 360px);
border-radius: 16px;
padding: 4px;
overflow: hidden;
isolation: isolate;
}
.band::before {
content: "";
position: absolute;
inset: -40%;
background: conic-gradient(from 0deg, transparent 0%, rgba(47, 109, 246, 0.95) 18%, transparent 40%);
animation: bandSpin 2s linear infinite;
}
.band__inner {
position: relative;
z-index: 1;
border-radius: calc(16px - 4px);
background: #17141a;
padding: 32px 24px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.band__title {
margin: 0;
color: #fff7e6;
font: 700 clamp(15px, 3.8vw, 19px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.band__cta {
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
padding: 8px 20px;
font-weight: 600;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
}
@keyframes bandSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.band::before {
animation: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./CtaBandLinear.module.scss";
export type CtaBandLinearProps = {
title?: string;
cta?: string;
/** 테두리 그라디언트가 한 바퀴 도는 시간(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CtaBandLinear({
title = "Move your team over today",
cta = "Start for free",
duration = 2000,
easing = "linear",
color = "#2f6df6",
}: CtaBandLinearProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.band} style={vars}>
<div className={styles.inner}>
<h3 className={styles.title}>{title}</h3>
<button type="button" className={styles.cta}>{cta}</button>
</div>
</section>
);
}07 · Gradient-border band — Measured area 2.754% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The 2px sliver trick
The trick underneath is a conic-gradient layer sized larger than the box and spun with transform: rotate(), with an opaque inner panel covering everything but a 2px sliver at the edge.
<div class="relative overflow-hidden rounded-card border border-accent/40
bg-cream/[.03] px-10 py-12 text-center shadow-glow">
<h3 class="mx-auto max-w-md text-3xl font-extrabold text-cream">
Move your team over today
</h3>
<button class="mt-6 rounded-pill bg-accent px-6 py-3 text-sm font-bold text-cream">
Start free
</button>
</div>
08Footer
Columns fading up in order
Four columns of links fade up left to right, one after another, until the dark footer is filled in.

<main class="stage">
<footer class="footer is-demo">
<div class="footer__col footer__col--1">
<p class="footer__head">Product</p>
<p class="footer__link">Features</p>
</div>
<div class="footer__col footer__col--2">
<p class="footer__head">Company</p>
<p class="footer__link">About</p>
</div>
<div class="footer__col footer__col--3">
<p class="footer__head">Resources</p>
<p class="footer__link">Docs</p>
</div>
<div class="footer__col footer__col--4">
<p class="footer__head">Support</p>
<p class="footer__link">Contact</p>
</div>
</footer>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 열 하나가 떠오르는 한 주기
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.footer {
box-sizing: border-box; // width 에 padding 을 포함시켜 320px 에서 넘치지 않게
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-4;
width: clamp(240px, 86vw, 360px);
background: $subject-ink;
border-radius: $r-card;
padding: $sp-6;
}
.footer__col {
display: flex;
flex-direction: column;
gap: $sp-2;
// 데모 루프는 .is-demo 안에서만 — 정적(실사용) 상태는 opacity 1 이라야 글자 대비가 산다
.footer.is-demo & { animation-name: colRise; }
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.footer__col--1 { animation-delay: 0s; }
.footer__col--2 { animation-delay: $duration * -0.75; }
.footer__col--3 { animation-delay: $duration * -0.5; }
.footer__col--4 { animation-delay: $duration * -0.25; }
.footer__head {
margin: 0;
color: $subject-blue-light; // 먹 면 위 파란 글자 — AA 4.5 (blog-design 글자 대비)
font: 700 clamp(9px, 2.2vw, 10.5px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-transform: uppercase;
letter-spacing: .04em;
}
.footer__link {
margin: 0;
color: rgba($subject-cream, .7);
font-weight: 600; font-size: clamp(10px, 2.4vw, 11.5px); line-height: 1.4;
}
@keyframes colRise {
0%, 62%, 100% { opacity: .35; transform: translateY(4px); }
17% { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.footer__col { animation: none !important; opacity: 1; transform: none; }
}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);
}
.footer {
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
width: clamp(240px, 86vw, 360px);
background: #17141a;
border-radius: 16px;
padding: 24px;
}
.footer__col {
display: flex;
flex-direction: column;
gap: 8px;
}
.footer.is-demo .footer__col {
animation-name: colRise;
}
.footer__col {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.footer__col--1 {
animation-delay: 0s;
}
.footer__col--2 {
animation-delay: -1.5s;
}
.footer__col--3 {
animation-delay: -1s;
}
.footer__col--4 {
animation-delay: -0.5s;
}
.footer__head {
margin: 0;
color: #5b8cf8;
font: 700 clamp(9px, 2.2vw, 10.5px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.footer__link {
margin: 0;
color: rgba(255, 247, 230, 0.7);
font-weight: 600;
font-size: clamp(10px, 2.4vw, 11.5px);
line-height: 1.4;
}
@keyframes colRise {
0%, 62%, 100% {
opacity: 0.35;
transform: translateY(4px);
}
17% {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.footer__col {
animation: none !important;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./FooterLinear.module.scss";
export type FooterLinearProps = {
columns?: { head: string; link: string }[];
/** 열 하나가 떠오르는 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_COLUMNS = [
{ head: "제품", link: "기능" },
{ head: "회사", link: "소개" },
{ head: "리소스", link: "문서" },
{ head: "고객지원", link: "문의" },
];
export default function FooterLinear({
columns = DEFAULT_COLUMNS,
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: FooterLinearProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const delays = columns.map((_, i) => `${-duration * (0.75 - i * 0.25)}ms`);
return (
<footer className={styles.footer} style={vars}>
{columns.map((col, i) => (
<div key={col.head} className={styles.col} style={{ animationDelay: i === 0 ? "0s" : delays[i] }}>
<p className={styles.head}>{col.head}</p>
<p className={styles.link}>{col.link}</p>
</div>
))}
</footer>
);
}08 · 4-column dark footer — Measured area 1.578% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Gaps instead of dividers
There's no divider line between the columns anywhere — the gap between them is the only thing marking where one group ends and the next begins.
<footer class="grid grid-cols-4 gap-gutter border-t border-line py-10">
<div>
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-accent">Product</p>
<p class="text-sm text-cream/70">Features</p>
</div>
<div>
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-accent">Company</p>
<p class="text-sm text-cream/70">About</p>
</div>
</footer>
09Buttons & Inputs
What moves in a dark mode landing page ui
The primary button and the email field each breathe a soft blue glow on their own loop, while the ghost button sits still with no animation at all.

<main class="stage">
<form class="form is-demo">
<label class="form__field">
<input class="form__input" type="email" placeholder="Email address" readonly>
</label>
<div class="form__row">
<button class="form__ghost" type="button">Learn more</button>
<button class="form__primary" type="button">Start for free</button>
</div>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 글로우·포커스 링이 커졌다 작아지는 한 주기
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.form {
display: flex;
flex-direction: column;
gap: $sp-4;
width: clamp(210px, 74vw, 280px);
}
.form__field {
display: block;
border-radius: $r-control;
background: rgba($subject-cream, .05);
border: 1px solid rgba($subject-cream, .12);
}
.form__input {
width: 100%;
box-sizing: border-box;
background: transparent;
border: 0;
outline: none;
padding: $sp-3 $sp-4;
color: $subject-cream;
font: 600 clamp(11px, 2.8vw, 13px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
border-radius: $r-control;
animation: inputRing $duration $easing infinite;
}
.form__row {
display: flex;
gap: $sp-3;
}
.form__ghost {
flex: 1 1 0;
border-radius: $r-control;
background: transparent;
border: 1px solid rgba($subject-cream, .2);
color: $subject-cream;
padding: $sp-3 $sp-4;
font-weight: 600; font-size: clamp(10.5px, 2.6vw, 12px); line-height: 1;
}
.form__primary {
flex: 1 1 0;
border-radius: $r-control;
border: 0;
background: $subject-blue-deep;
color: $subject-cream;
padding: $sp-3 $sp-4;
font-weight: 600; font-size: clamp(10.5px, 2.6vw, 12px); line-height: 1;
animation: btnGlow $duration $easing infinite;
}
@keyframes inputRing {
0%, 100% { box-shadow: 0 0 0 0 rgba($color, 0); }
50% { box-shadow: 0 0 0 3px rgba($color, .45); }
}
@keyframes btnGlow {
0%, 100% { box-shadow: 0 0 0 0 rgba($color, 0); }
50% { box-shadow: 0 6px 18px rgba($color, .55); }
}
@media (prefers-reduced-motion: reduce) {
.form__input, .form__primary { animation: none !important; box-shadow: none; }
}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);
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
width: clamp(210px, 74vw, 280px);
}
.form__field {
display: block;
border-radius: 12px;
background: rgba(255, 247, 230, 0.05);
border: 1px solid rgba(255, 247, 230, 0.12);
}
.form__input {
width: 100%;
box-sizing: border-box;
background: transparent;
border: 0;
outline: none;
padding: 12px 16px;
color: #fff7e6;
font: 600 clamp(11px, 2.8vw, 13px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
border-radius: 12px;
animation: inputRing 2s ease-out infinite;
}
.form__row {
display: flex;
gap: 12px;
}
.form__ghost {
flex: 1 1 0;
border-radius: 12px;
background: transparent;
border: 1px solid rgba(255, 247, 230, 0.2);
color: #fff7e6;
padding: 12px 16px;
font-weight: 600;
font-size: clamp(10.5px, 2.6vw, 12px);
line-height: 1;
}
.form__primary {
flex: 1 1 0;
border-radius: 12px;
border: 0;
background: #0a4fe8;
color: #fff7e6;
padding: 12px 16px;
font-weight: 600;
font-size: clamp(10.5px, 2.6vw, 12px);
line-height: 1;
animation: btnGlow 2s ease-out infinite;
}
@keyframes inputRing {
0%, 100% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0);
}
50% {
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.45);
}
}
@keyframes btnGlow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0);
}
50% {
box-shadow: 0 6px 18px rgba(47, 109, 246, 0.55);
}
}
@media (prefers-reduced-motion: reduce) {
.form__input, .form__primary {
animation: none !important;
box-shadow: none;
}
}import type { CSSProperties } from "react";
import styles from "./ButtonsInputsLinear.module.scss";
export type ButtonsInputsLinearProps = {
placeholder?: string;
ghostLabel?: string;
primaryLabel?: string;
/** 글로우·포커스 링이 커졌다 작아지는 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ButtonsInputsLinear({
placeholder = "Email address",
ghostLabel = "Learn more",
primaryLabel = "Start for free",
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: ButtonsInputsLinearProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<form className={styles.form} style={vars}>
<label className={styles.field}>
<input className={styles.input} type="email" placeholder={placeholder} readOnly />
</label>
<div className={styles.row}>
<button type="button" className={styles.ghost}>{ghostLabel}</button>
<button type="button" className={styles.primary}>{primaryLabel}</button>
</div>
</form>
);
}09 · Ghost/primary buttons, dark inputs — Measured area 4.033% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
One radius across the page
All three share the same corner radius as the cards elsewhere on the page, so the form reads as part of one system rather than a widget dropped in from somewhere else.
<form class="mx-auto flex max-w-md flex-col gap-3 rounded-card border
border-line bg-cream/[.03] p-6 sm:flex-row">
<label class="relative flex-1 rounded-control border border-line bg-cream/5">
<input class="w-full rounded-control bg-transparent px-4 py-3 text-sm
text-cream outline-none" type="email" placeholder="Email address">
</label>
<button class="rounded-control bg-accent px-5 py-3 text-sm font-bold text-cream">
Sign up
</button>
</form>
Custom tailwind.config
One config for every part
Every part above, plus the one-page mock, reads from this single config.
Change a value, shift the tone
Change a background, accent, radius, or shadow value here and the whole page shifts tone at once instead of section by section.
theme: {
extend: {
colors: {
ink: "#17141a",
cream: "#fff7e6",
accent: "#2f6df6",
line: "rgba(255,247,230,.08)",
},
fontFamily: {
display: ["'Inter'", "system-ui", "sans-serif"],
body: ["'Inter'", "system-ui", "sans-serif"],
},
borderRadius: { control: "12px", card: "16px", pill: "999px" },
boxShadow: {
raised: "0 14px 24px rgba(23,20,26,.32)",
glow: "0 0 40px rgba(47,109,246,.35)",
},
spacing: { gutter: "16px" },
},
},
Vibe-coding prompt
A short instruction is enough
Paste the config above into a project first; then a short instruction to Claude Code or Cursor is enough to keep the style consistent. The full prompt text ships in the zip's prompt.md.
Background is ink (#17141a) only — nothing else. Cards aren't pure white either;
they're ink with a faint cream overlay (3-5%) laid on top.
Reserve accent (#2f6df6) for the spots that need it: buttons, the recommended
badge, focus rings. Never add a second accent color.
Radius comes from only three values: rounded-card, rounded-pill, rounded-control.
What it produced
Running that instruction against page/index.html produced the result below, captured as a full-page screenshot at a 1280px viewport.

Where it breaks (the trap)
Why theme() falls back to white
The most common way this style breaks is setting the background with a theme() function call. The Tailwind Play CDN compiles utility classes like bg-ink correctly, but a raw background: theme('colors.ink') written inside a <style> tag gets ignored and silently falls back to a plain white background. This project hit exactly that on the first pass — the <body> background was set through theme() inside a <style> block, the whole one-page mock rendered on white, and the pricing card's text-cream/60 text nearly vanished against it in the screenshot before anyone caught it. Switching to a compiled utility class, <body class="bg-ink">, is what actually applied the ink background. A dark surface swallowing bright text repeats as an unsupported-dark-mode failure in the transactional email templates, where the palette flips the other way. The second trap is radius. Any card that drifts to an arbitrary px value outside rounded-card, rounded-pill, and rounded-control breaks the sense that the whole page is one system rather than several sites stitched together. The zip bundling all nine parts and the one-page source is locked, and the password is wdu6yd23, typed exactly as shown — unlocked with the button just below.
Narrow screens under 320px
A third trap shows up under 320px if you skip clamp() altogether. Section 01's hero mock keeps its width and padding on clamp() for exactly that reason — swap any of those calls back to a fixed px value and the card grows taller than the viewport, forcing a scrollbar.
Accessibility
What reduced motion settles to
All nine demos are wrapped in the media query defined by MDN's prefers-reduced-motion documentation, and a visitor with reduced motion turned on sees the finished, settled state of each one instead of the rotating, glowing, or sweeping loop.
Color is never the only signal
Anything that signals state through color alone — the active pill in 02, the dimmed logos in 06 — is marked aria-hidden, and the real meaning behind it (which menu item, which partner name) sits nearby as plain text either way.
Blur fallback and further reading
Section 02 also follows MDN's backdrop-filter documentation by keeping the nav's own translucent background under the blur, so text contrast holds up even on a browser that skips the blur entirely.
More saas landing page examples like these sit in the design category hub, and what this project sells — and what it doesn't — is on the about page.
FAQ
Can I use the Linear logo or brand name in these sections?
No. Nothing here copies Linear.app's logo, trademark, or actual screens — only the general design language carries over: a dark background, a blue accent, and monospace labels. Every brand name in the demo, including the nav mark, is the fictional "Vantage," not Linear's own name.
Does this dark saas landing page work as a light-mode version too?
Yes. Swapping the roles that ink and cream play is enough, but the part that has to survive that swap either way is a single accent color doing all the pointing. Add a second accent and a reader loses the one signal telling them which element actually matters.
Can I use this without Tailwind, in plain CSS?
Yes — link the tokens.css file included in the zip. It carries the same values as tailwind.config.js, just written as CSS custom properties (--color-ink, --color-accent, and so on), so referencing var(--color-accent) directly keeps the same tone without any Tailwind classes at all. For a promo block carrying discount copy and a deadline, the promotion landing templates hold the assembly drawing.









