04Recommended plan grows, neighbors step back
The middle card wears a slanted ribbon, and hovering any card brings that one forward while its left and right neighbors settle back.
Best forLanding sections that pull attention to the middle tier of three
:has()scalerotate

<main class="stage">
<div class="pr is-demo">
<div class="pr__row">
<button class="pr__card" type="button" data-plan="starter">
<span class="pr__name">Starter</span>
<span class="pr__price">
<span class="pr__amount">$9</span>
<span class="pr__unit">/ mo</span>
</span>
</button>
<button class="pr__card pr__card--pick" type="button" data-plan="pro">
<span class="pr__ribbon">Popular</span>
<span class="pr__name">Pro</span>
<span class="pr__price">
<span class="pr__amount">$29</span>
<span class="pr__unit">/ mo</span>
</span>
</button>
<button class="pr__card" type="button" data-plan="business">
<span class="pr__name">Business</span>
<span class="pr__price">
<span class="pr__amount">$79</span>
<span class="pr__unit">/ mo</span>
</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.pr {
box-sizing: border-box;
width: min(440px, 92vw);
// 리본은 카드 위로 삐져나온다. 절대배치는 부모 높이 계산에서 빠지지만 scrollHeight 에는 잡히니
// 뿌리에서 그만큼(리본 16px + 카드가 뜨는 6px + scale 여유)을 미리 비워 둔다
padding-top: $sp-8;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
// 균등 칸은 grid 1fr 로 — flex: 1 1 0 위에 고정 px 를 올리지 않는다
.pr__row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
align-items: stretch;
}
.pr__card {
position: relative;
box-sizing: border-box;
margin: 0;
padding: $sp-4 $sp-2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-2;
border: 2px solid rgba(0, 0, 0, .12);
border-radius: $r-card;
background-color: rgba(0, 0, 0, .03);
color: $subject-ink;
font-family: inherit;
cursor: pointer;
transform: scale(1);
transform-origin: 50% 100%; // 규칙8: 카드 석 장이 한 바닥선을 딛고 커진다 — 여백은 리본 쪽(위)만 비워 뒀다
transition: transform $duration $easing;
}
// 가운데 추천 카드만 색을 채운다 — 리본과 한 덩어리로 읽힌다
.pr__card--pick {
border-color: $color;
background-color: $color;
color: $stage-paper; // 크림은 이 파랑 위에서 4.25:1 로 모자란다 — 순백이어야 4.53:1 로 기준을 넘는다
box-shadow: $shadow-raised;
}
.pr__card--pick .pr__unit { opacity: 1; } // 72% 로 옅게 두면 3.10:1 로 떨어진다
.pr__card:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.pr__name {
font-size: 12px;
line-height: 18px;
font-weight: 700;
}
.pr__price {
display: flex;
align-items: baseline;
gap: $sp-1;
}
.pr__amount {
font-size: 22px;
line-height: 28px;
font-weight: 800;
}
.pr__unit {
font-size: 11px;
line-height: 16px;
font-weight: 600;
opacity: .72;
}
// 비스듬한 리본 — left/right 0 + auto 여백으로 가운데를 잡아 두면 transform 은 회전만 맡는다
.pr__ribbon {
position: absolute;
top: -16px;
left: 0;
right: 0;
width: fit-content;
margin-left: auto;
margin-right: auto;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background-color: $subject-ink;
color: $subject-cream;
font-size: 11px;
line-height: 16px;
font-weight: 800;
white-space: nowrap;
box-shadow: $shadow-press;
transform: rotate(-8deg);
transform-origin: 50% 50%;
}
// ── 이웃이 물러난다 — :has() 로 줄 전체를 줄이고, 그 위에 호버한 카드만 앞으로 ──
// 물러나는 규칙이 :has() 때문에 더 구체적이라, 앞으로 나오는 규칙에도 같은 :has() 를 붙여야 이긴다
.pr__row:has(.pr__card:hover) .pr__card,
.pr__row:has(.pr__card:focus-within) .pr__card { transform: scale(.94); }
.pr__row:has(.pr__card:hover) .pr__card:hover,
.pr__row:has(.pr__card:focus-within) .pr__card:focus-within { transform: scale(1.06); }
// ── 자동 루프: 세 카드가 왼쪽부터 차례로 올라왔다 내려간다 ──
// 시차는 animation-delay 로, 늦게 시작하는 카드가 0프레임에 완성 상태로 번쩍이지 않게 fill-mode: backwards
.pr.is-demo .pr__card {
animation-name: pr-lift;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pr.is-demo .pr__card[data-plan="starter"] { animation-delay: 0ms; }
.pr.is-demo .pr__card[data-plan="pro"] { animation-delay: 300ms; }
.pr.is-demo .pr__card[data-plan="business"] { animation-delay: 600ms; }
@keyframes pr-lift {
0% { transform: translateY(0) scale(1); }
14% { transform: translateY(-6px) scale(1.05); }
34% { transform: translateY(0) scale(1); }
100% { transform: translateY(0) scale(1); }
}
// 리본은 추천 카드와 같은 시계로 한 번 더 기울었다 돌아온다
.pr.is-demo .pr__ribbon {
animation-name: pr-ribbon;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: 300ms;
animation-fill-mode: backwards;
}
@keyframes pr-ribbon {
0% { transform: rotate(-8deg) scale(1); }
14% { transform: rotate(-13deg) scale(1.1); }
34% { transform: rotate(-8deg) scale(1); }
100% { transform: rotate(-8deg) scale(1); }
}
@media (max-width: 340px) {
// padding-top 은 줄이지 않는다 — 리본이 나가는 높이는 화면이 좁아져도 그대로다
.pr__row { gap: $sp-1; }
.pr__card { padding: $sp-3 $sp-1; gap: $sp-1; }
.pr__name { font-size: 11px; line-height: 16px; }
.pr__amount { font-size: 18px; line-height: 24px; }
.pr__unit { font-size: 10px; line-height: 14px; }
.pr__ribbon { top: -12px; padding: 0 $sp-2; font-size: 10px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.pr__card { transition: none; }
.pr.is-demo .pr__card,
.pr.is-demo .pr__ribbon { animation: none !important; }
.pr.is-demo .pr__ribbon { transform: rotate(-8deg); }
}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 {
box-sizing: border-box;
width: min(440px, 92vw);
padding-top: 32px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pr__row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
align-items: stretch;
}
.pr__card {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 16px 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border: 2px solid rgba(0, 0, 0, 0.12);
border-radius: 16px;
background-color: rgba(0, 0, 0, 0.03);
color: #17141a;
font-family: inherit;
cursor: pointer;
transform: scale(1);
transform-origin: 50% 100%;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pr__card--pick {
border-color: #2f6df6;
background-color: #2f6df6;
color: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.pr__card--pick .pr__unit {
opacity: 1;
}
.pr__card:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pr__name {
font-size: 12px;
line-height: 18px;
font-weight: 700;
}
.pr__price {
display: flex;
align-items: baseline;
gap: 4px;
}
.pr__amount {
font-size: 22px;
line-height: 28px;
font-weight: 800;
}
.pr__unit {
font-size: 11px;
line-height: 16px;
font-weight: 600;
opacity: 0.72;
}
.pr__ribbon {
position: absolute;
top: -16px;
left: 0;
right: 0;
width: fit-content;
margin-left: auto;
margin-right: auto;
padding: 4px 12px;
border-radius: 999px;
background-color: #17141a;
color: #fff7e6;
font-size: 11px;
line-height: 16px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: rotate(-8deg);
transform-origin: 50% 50%;
}
.pr__row:has(.pr__card:hover) .pr__card,
.pr__row:has(.pr__card:focus-within) .pr__card {
transform: scale(0.94);
}
.pr__row:has(.pr__card:hover) .pr__card:hover,
.pr__row:has(.pr__card:focus-within) .pr__card:focus-within {
transform: scale(1.06);
}
.pr.is-demo .pr__card {
animation-name: pr-lift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pr.is-demo .pr__card[data-plan=starter] {
animation-delay: 0ms;
}
.pr.is-demo .pr__card[data-plan=pro] {
animation-delay: 300ms;
}
.pr.is-demo .pr__card[data-plan=business] {
animation-delay: 600ms;
}
@keyframes pr-lift {
0% {
transform: translateY(0) scale(1);
}
14% {
transform: translateY(-6px) scale(1.05);
}
34% {
transform: translateY(0) scale(1);
}
100% {
transform: translateY(0) scale(1);
}
}
.pr.is-demo .pr__ribbon {
animation-name: pr-ribbon;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-delay: 300ms;
animation-fill-mode: backwards;
}
@keyframes pr-ribbon {
0% {
transform: rotate(-8deg) scale(1);
}
14% {
transform: rotate(-13deg) scale(1.1);
}
34% {
transform: rotate(-8deg) scale(1);
}
100% {
transform: rotate(-8deg) scale(1);
}
}
@media (max-width: 340px) {
.pr__row {
gap: 4px;
}
.pr__card {
padding: 12px 4px;
gap: 4px;
}
.pr__name {
font-size: 11px;
line-height: 16px;
}
.pr__amount {
font-size: 18px;
line-height: 24px;
}
.pr__unit {
font-size: 10px;
line-height: 14px;
}
.pr__ribbon {
top: -12px;
padding: 0 8px;
font-size: 10px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.pr__card {
transition: none;
}
.pr.is-demo .pr__card,
.pr.is-demo .pr__ribbon {
animation: none !important;
}
.pr.is-demo .pr__ribbon {
transform: rotate(-8deg);
}
}// 실제 동작은 전부 CSS 가 한다 — .row:has(.card:hover) 가 줄 전체를 물러나게 하고 호버한 카드만 앞으로 나온다.
// JS 가 맡는 일은 하나뿐: 사람이 건드린 순간 자동 루프(is-demo)를 끈다.
var root = document.querySelector('.pr');
var row = document.querySelector('.pr__row');
function stop() { root.classList.remove('is-demo'); }
row.addEventListener('pointerenter', stop);
row.addEventListener('focusin', stop);
row.addEventListener('click', stop);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RecommendedRibbon.module.scss";
export type RibbonPlan = { id: string; name: string; amount: string; picked?: boolean };
export type RecommendedRibbonProps = {
plans?: RibbonPlan[];
unit?: string;
ribbon?: string;
duration?: number;
easing?: string;
color?: string;
/** 한 카드가 올라오고 다음 카드가 올라오기까지의 시차 */
stagger?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CardVars = CSSProperties & { "--d"?: string };
const PLANS: RibbonPlan[] = [
{ id: "starter", name: "Starter", amount: "$9" },
{ id: "pro", name: "Pro", amount: "$29", picked: true },
{ id: "business", name: "Business", amount: "$79" },
];
export default function RecommendedRibbon({
plans = PLANS,
unit = "/ mo",
ribbon = "Popular",
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
stagger = 300,
}: RecommendedRibbonProps) {
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 사람이 건드리면 자동 루프를 끈다 — 호버로 물러나고 앞으로 나오는 일은 CSS :has() 가 맡는다
const stop = () => setDemo(false);
return (
<div className={`${styles.pr} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.row} onPointerEnter={stop} onFocus={stop} onClick={stop}>
{plans.map((p, i) => (
<button
key={p.id}
type="button"
className={`${styles.card} ${p.picked ? styles.picked : ""}`}
style={{ "--d": `${i * stagger}ms` } as CardVars}
>
{p.picked ? <span className={styles.ribbon}>{ribbon}</span> : null}
<span className={styles.name}>{p.name}</span>
<span className={styles.price}>
<span className={styles.amount}>{p.amount}</span>
<span className={styles.unit}>{unit}</span>
</span>
</button>
))}
</div>
</div>
);
}04 Recommended plan grows, neighbors step back — Measured area 11.803% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Pricing Table Design: 9 Live Interactions
The post this demo lives in
Pricing Table Design: 9 Live InteractionsIn pricing table design, the work happens on top of the cards, not inside them. All nine run on vanilla JS and SCSS, and See all demos in this post


