CSS 카드 효과 9가지 — 호버 인터랙션 모음
CSS 카드 효과는 마우스를 올리거나 카드를 선택했을 때 상품·포스트 카드가 살짝 움직이면서 정보를 더 보여주는 짧은 시각 변화입니다. 상품·포스트 카드에 바로 붙일 수 있는 아홉 가지를 실제로 렌더링해서 모았고, 전부 자바스크립트 없이 CSS만으로 움직입니다.
01 card-hover-image-zoom-caption
02 card-hover-tilt-3d-zones
03 card-hover-flip-reveal
04 card-hover-corner-frame-expand
05 card-hover-badge-pop-elastic
06 card-hover-cta-underline-sweep
07 card-hover-anchored-tooltip-panel
08 card-hover-glassmorphism-blur
09 card-hover-select-ring-draw
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 이미지 확대 + 캡션 슬라이드
- 02 구역별 3D 틸트 (CSS만)
- 03 뒷면 플립 공개
- 04 모서리 브래킷 프레임
- 05 뱃지 탄성 팝인
- 06 CTA 밑줄 스윕 (부모 호버)
- 07 앵커 위치 미니 패널
- 08 글래스모피즘 블러
- 09 선택 링 드로잉
글자를 다루는 쪽 순서가 궁금하면 CSS 텍스트 애니메이션과 디코드 연출 쪽이 같은 feedback 기둥의 앞뒤 편입니다. 아홉 개는 인기 순서가 아니라 css 카드 디자인에서 카드 한 장을 눈으로 훑어볼 때 실제로 거치는 순서로 놓았습니다. 먼저 이미지가 확대되며 존재감을 드러내고(01), 커서가 머무는 위치에 따라 기울고(02), 궁금해서 더 보면 뒷면이 뒤집히고(03), 초점이 맞았다는 걸 프레임과 뱃지와 밑줄이 몸짓으로 보여주고(04~06), 잠깐 멈추면 정보 패널이 뜨고(07), 프리미엄한 유리 표면으로 눈길을 붙잡다가(08), 마지막으로 링 하나가 그려지며 실제 선택을 확정합니다(09).
| 번호 | 이름 | 무엇이 움직이나 | JS |
|---|---|---|---|
| 01 | 이미지 확대 + 캡션 슬라이드 | 이미지 scale, 캡션 translateY |
없음 |
| 02 | 구역별 3D 틸트 | 카드 rotateX·rotateY |
없음 |
| 03 | 뒷면 플립 공개 | 카드 rotateY(180deg) |
없음 |
| 04 | 모서리 브래킷 프레임 | 브래킷 opacity·위치 |
없음 |
| 05 | 뱃지 탄성 팝인 | 뱃지 scale 오버슈트 |
없음 |
| 06 | CTA 밑줄 스윕 | 밑줄 scaleX |
없음 |
| 07 | 앵커 위치 미니 패널 | 패널 opacity·위치 |
없음 |
| 08 | 글래스모피즘 블러 | backdrop-filter·그림자 |
없음 |
| 09 | 선택 링 드로잉 | SVG stroke-dashoffset |
없음 |
01이미지 확대 + 캡션 슬라이드
css 카드 애니메이션의 기본 확대
카드에 마우스를 올리면 안쪽 이미지가 1.08배로 천천히 확대되고, 동시에 아래에 숨어 있던 캡션이 위로 슬라이드해 겹쳐집니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<div class="card__frame">
<span class="card__media" aria-hidden="true"></span>
<div class="card__caption">
<h3 class="card__title">발리 우붓 3박4일</h3>
<p class="card__desc">논밭 사이 빌라 · 야간투어</p>
</div>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 확대·캡션이 움직이는 시간
$easing: $ease-out;
$color: $subject-blue; // 미디어 그라디언트
@include stage($stage-ink);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
width: min(160px, 50vw);
border-radius: $r-card;
overflow: hidden;
background: $stage-paper;
box-shadow: $shadow-press;
outline: none;
cursor: pointer;
}
.card__frame { position: relative; aspect-ratio: 4 / 3; overflow: hidden; }
.card__media {
position: absolute; inset: 0;
background: linear-gradient(155deg, $color 0%, $stage-ink 100%);
transform: scale(1);
transition: transform $duration $easing;
}
.card__caption {
position: absolute; left: 0; right: 0; bottom: 0;
padding: $sp-2 $sp-3;
background: linear-gradient(0deg, rgba($stage-ink, .85), rgba($stage-ink, 0));
transform: translateY(60%);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.card__desc { margin: 0; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.3; font-family: inherit; color: rgba($stage-paper, .85); }
.card:hover .card__media,
.card:focus-visible .card__media,
.card:active .card__media {
transform: scale(1.08);
}
.card:hover .card__caption,
.card:focus-visible .card__caption,
.card:active .card__caption {
transform: translateY(0);
opacity: 1;
}
.card.is-demo .card__media { animation: zoom-loop $dur-loop $easing infinite; }
.card.is-demo .card__caption { animation: caption-loop $dur-loop $easing infinite; }
@keyframes zoom-loop {
0%, 15% { transform: scale(1); }
45%, 75% { transform: scale(1.08); }
100% { transform: scale(1); }
}
@keyframes caption-loop {
0%, 15% { transform: translateY(60%); opacity: 0; }
45%, 75% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(60%); opacity: 0; }
}
@media (hover: none) {
.card:active .card__media { transform: scale(1.08); }
.card:active .card__caption { transform: translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.card__media, .card__caption { transition: none; }
.card.is-demo .card__media, .card.is-demo .card__caption { animation: none; }
.card__caption { transform: translateY(0); opacity: 1; }
}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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.card {
width: min(160px, 50vw);
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
}
.card__frame {
position: relative;
aspect-ratio: 4/3;
overflow: hidden;
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
transform: scale(1);
transition: transform 300ms ease-out;
}
.card__caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px 12px;
background: linear-gradient(0deg, rgba(23, 20, 26, 0.85), rgba(23, 20, 26, 0));
transform: translateY(60%);
opacity: 0;
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.card__desc {
margin: 0;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.85);
}
.card:hover .card__media,
.card:focus-visible .card__media,
.card:active .card__media {
transform: scale(1.08);
}
.card:hover .card__caption,
.card:focus-visible .card__caption,
.card:active .card__caption {
transform: translateY(0);
opacity: 1;
}
.card.is-demo .card__media {
animation: zoom-loop 2s ease-out infinite;
}
.card.is-demo .card__caption {
animation: caption-loop 2s ease-out infinite;
}
@keyframes zoom-loop {
0%, 15% {
transform: scale(1);
}
45%, 75% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}
@keyframes caption-loop {
0%, 15% {
transform: translateY(60%);
opacity: 0;
}
45%, 75% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(60%);
opacity: 0;
}
}
@media (hover: none) {
.card:active .card__media {
transform: scale(1.08);
}
.card:active .card__caption {
transform: translateY(0);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.card__media, .card__caption {
transition: none;
}
.card.is-demo .card__media, .card.is-demo .card__caption {
animation: none;
}
.card__caption {
transform: translateY(0);
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./ZoomCaptionCard.module.scss";
export type ZoomCaptionCardProps = {
title: string;
desc: string;
/** 확대·캡션이 움직이는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 미디어 그라디언트 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ZoomCaptionCard({
title,
desc,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: ZoomCaptionCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<div className={styles.frame}>
<span className={styles.media} aria-hidden="true" />
<div className={styles.caption}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.desc}>{desc}</p>
</div>
</div>
</article>
);
}01 · 이미지 확대 + 캡션 슬라이드 — 측정 누적면적 4.11% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
이미지가 주인공인 자리
여행지·상품 소개 카드나 블로그 썸네일처럼 이미지가 주인공인 자리에 씁니다.
.card__media {
transform: scale(1);
transition: transform $duration $easing;
}
.card__caption {
transform: translateY(60%);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.card:hover .card__media,
.card.is-demo .card__media { transform: scale(1.08); }
.card:hover .card__caption,
.card.is-demo .card__caption { transform: translateY(0); opacity: 1; }
02구역별 3D 틸트
구역에 따라 기울기가 바뀐다
카드를 4등분한 보이지 않는 구역 중 커서가 올라간 곳에 따라 :has() 선택자가 다른 기울기 값을 고릅니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<div class="card__zones" aria-hidden="true">
<span class="zone zone--tl"></span>
<span class="zone zone--tr"></span>
<span class="zone zone--bl"></span>
<span class="zone zone--br"></span>
</div>
<div class="card__inner">
<h3 class="card__title">신규 릴리즈</h3>
<p class="card__meta">Q3 로드맵 보기</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 기울어지는 시간
$easing: $ease-spring;
$color: $subject-cream; // 안쪽 패널 배경
@include stage($stage-yellow);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
perspective: 600px;
outline: none;
cursor: pointer;
}
.card__zones {
position: absolute; inset: 0;
z-index: 2;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
border-radius: $r-card;
overflow: hidden;
}
.zone { display: block; }
.card__inner {
height: 100%;
padding: $sp-3;
box-sizing: border-box;
border-radius: $r-card;
background: $color;
box-shadow: $shadow-press;
display: flex;
flex-direction: column;
justify-content: center;
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .65); }
// 구역 4개 중 어디에 커서가 있는지에 따라 :has() 가 다른 기울기를 고른다 — JS 좌표 추적이 없다
.card:has(.zone--tl:hover) .card__inner,
.card:focus-visible .card__inner { transform: rotateX(8deg) rotateY(-10deg); }
.card:has(.zone--tr:hover) .card__inner { transform: rotateX(8deg) rotateY(10deg); }
.card:has(.zone--bl:hover) .card__inner { transform: rotateX(-8deg) rotateY(-10deg); }
.card:has(.zone--br:hover) .card__inner { transform: rotateX(-8deg) rotateY(10deg); }
.card.is-demo .card__inner { animation: tilt-loop $dur-loop $easing infinite; }
@keyframes tilt-loop {
0%, 10% { transform: rotateX(0) rotateY(0); }
25% { transform: rotateX(8deg) rotateY(-10deg); }
50% { transform: rotateX(8deg) rotateY(10deg); }
75% { transform: rotateX(-8deg) rotateY(-10deg); }
90%, 100% { transform: rotateX(0) rotateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.card__inner { transition: none; }
.card.is-demo .card__inner { animation: none; transform: none; }
}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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
perspective: 600px;
outline: none;
cursor: pointer;
}
.card__zones {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
border-radius: 16px;
overflow: hidden;
}
.zone {
display: block;
}
.card__inner {
height: 100%;
padding: 12px;
box-sizing: border-box;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
display: flex;
flex-direction: column;
justify-content: center;
transform-style: preserve-3d;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.65);
}
.card:has(.zone--tl:hover) .card__inner,
.card:focus-visible .card__inner {
transform: rotateX(8deg) rotateY(-10deg);
}
.card:has(.zone--tr:hover) .card__inner {
transform: rotateX(8deg) rotateY(10deg);
}
.card:has(.zone--bl:hover) .card__inner {
transform: rotateX(-8deg) rotateY(-10deg);
}
.card:has(.zone--br:hover) .card__inner {
transform: rotateX(-8deg) rotateY(10deg);
}
.card.is-demo .card__inner {
animation: tilt-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tilt-loop {
0%, 10% {
transform: rotateX(0) rotateY(0);
}
25% {
transform: rotateX(8deg) rotateY(-10deg);
}
50% {
transform: rotateX(8deg) rotateY(10deg);
}
75% {
transform: rotateX(-8deg) rotateY(-10deg);
}
90%, 100% {
transform: rotateX(0) rotateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card__inner {
transition: none;
}
.card.is-demo .card__inner {
animation: none;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./TiltZonesCard.module.scss";
export type TiltZonesCardProps = {
title: string;
meta: string;
/** 기울어지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 안쪽 패널 배경 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 구역 4개 중 마우스가 올라간 구역에 따라 :has() 가 기울기를 고른다 — JS 좌표 추적이 없다 */
export default function TiltZonesCard({
title,
meta,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: TiltZonesCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<div className={styles.zones} aria-hidden="true">
<span className={`${styles.zone} ${styles.zoneTl}`} />
<span className={`${styles.zone} ${styles.zoneTr}`} />
<span className={`${styles.zone} ${styles.zoneBl}`} />
<span className={`${styles.zone} ${styles.zoneBr}`} />
</div>
<div className={styles.inner}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}02 · 구역별 3D 틸트 (CSS만) — 측정 누적면적 2.811% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
JS 없이 커서 쪽으로
좌표를 읽는 JS 없이도 카드가 커서 쪽으로 기울어, 기능 소개 카드에 쓰기 좋습니다.
.card__inner {
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.card:has(.zone--tl:hover) .card__inner { transform: rotateX(8deg) rotateY(-10deg); }
.card:has(.zone--tr:hover) .card__inner { transform: rotateX(8deg) rotateY(10deg); }
.card:has(.zone--bl:hover) .card__inner { transform: rotateX(-8deg) rotateY(-10deg); }
.card:has(.zone--br:hover) .card__inner { transform: rotateX(-8deg) rotateY(10deg); }
03뒷면 플립 공개
css 카드 뒤집기 애니메이션으로 뒷면 공개
카드에 올리면 Y축으로 180도 돌아 뒷면이 나타나고, 앞에서 다 못 보여준 상세 설명이나 버튼이 뒷면에 있습니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<div class="card__flip">
<div class="card__face card__face--front">
<h3 class="card__title">프로 플랜</h3>
<p class="card__meta">월 12,000원</p>
</div>
<div class="card__face card__face--back">
<p class="card__back-text">무제한 내보내기<br>팀원 5명까지</p>
</div>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 뒤집히는 시간
$easing: $ease-spring;
$color: $subject-blue; // 뒷면 배경
@include stage($stage-orange);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
perspective: 800px;
outline: none;
cursor: pointer;
border-radius: $r-card;
}
.card__flip {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform $duration $easing;
border-radius: $r-card;
}
.card__face {
position: absolute; inset: 0;
backface-visibility: hidden;
border-radius: $r-card;
padding: $sp-3;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
}
.card__face--front { background: $stage-paper; box-shadow: $shadow-press; }
.card__face--back { background: $color; transform: rotateY(180deg); color: $stage-paper; }
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .65); }
.card__back-text { margin: 0; font-weight: 600; font-size: clamp(11px, 3vw, 12px); line-height: 1.5; font-family: inherit; }
.card:hover .card__flip,
.card:focus-visible .card__flip,
.card:active .card__flip {
transform: rotateY(180deg);
}
.card.is-demo .card__flip { animation: flip-loop $dur-loop $easing infinite; }
@keyframes flip-loop {
0%, 20% { transform: rotateY(0); }
50%, 75% { transform: rotateY(180deg); }
100% { transform: rotateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.card__flip { transition: none; }
.card.is-demo .card__flip { animation: none; transform: 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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.card {
width: min(150px, 48vw);
aspect-ratio: 4/3;
perspective: 800px;
outline: none;
cursor: pointer;
border-radius: 16px;
}
.card__flip {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
border-radius: 16px;
}
.card__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
border-radius: 16px;
padding: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
}
.card__face--front {
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.card__face--back {
background: #2f6df6;
transform: rotateY(180deg);
color: #ffffff;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.65);
}
.card__back-text {
margin: 0;
font-weight: 600;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.5;
font-family: inherit;
}
.card:hover .card__flip,
.card:focus-visible .card__flip,
.card:active .card__flip {
transform: rotateY(180deg);
}
.card.is-demo .card__flip {
animation: flip-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes flip-loop {
0%, 20% {
transform: rotateY(0);
}
50%, 75% {
transform: rotateY(180deg);
}
100% {
transform: rotateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card__flip {
transition: none;
}
.card.is-demo .card__flip {
animation: none;
transform: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./FlipRevealCard.module.scss";
export type FlipRevealCardProps = {
title: string;
meta: string;
back: ReactNode;
/** 뒤집히는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 뒷면 배경 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FlipRevealCard({
title,
meta,
back,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FlipRevealCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<div className={styles.flip}>
<div className={`${styles.face} ${styles.faceFront}`}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
<div className={`${styles.face} ${styles.faceBack}`}>{back}</div>
</div>
</article>
);
}03 · 뒷면 플립 공개 — 측정 누적면적 13.908% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
정보가 두 겹인 카드에
가격 카드의 세부 조건이나 팀원 소개처럼 정보가 두 겹인 카드에 맞습니다.
.card__flip {
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.card__face--back {
backface-visibility: hidden;
transform: rotateY(180deg);
}
.card:hover .card__flip,
.card:focus-visible .card__flip {
transform: rotateY(180deg);
}
04모서리 브래킷 프레임
네 귀퉁이의 브래킷
카드 네 귀퉁이에 짧게 접힌 브래킷이 평소엔 안쪽에 작게 숨어 있다가, 호버하면 테두리 쪽으로 자라나 카메라 포커스처럼 카드를 감쌉니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="corner corner--tl" aria-hidden="true"></span>
<span class="corner corner--tr" aria-hidden="true"></span>
<span class="corner corner--bl" aria-hidden="true"></span>
<span class="corner corner--br" aria-hidden="true"></span>
<span class="card__media" aria-hidden="true"></span>
<div class="card__body">
<h3 class="card__title">가을 컬렉션</h3>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 브래킷이 자라는 시간
$easing: $ease-out;
$color: $subject-blue; // 브래킷 색
@include stage($stage-paper);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
background: $stage-ink;
outline: none;
cursor: pointer;
}
.card__media {
position: absolute; inset: 0;
background: linear-gradient(155deg, $color 0%, $stage-ink 100%);
}
.card__body {
position: absolute; left: 0; right: 0; bottom: 0;
padding: $sp-2 $sp-3;
}
.card__title { margin: 0; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.corner {
position: absolute;
width: $sp-4;
height: $sp-4;
border: 2px solid $color;
opacity: 0;
z-index: 2;
transition: transform $duration $easing, opacity $duration $easing;
}
.corner--tl { top: $sp-2; left: $sp-2; border-right: none; border-bottom: none; transform: translate($sp-2, $sp-2); }
.corner--tr { top: $sp-2; right: $sp-2; border-left: none; border-bottom: none; transform: translate(-$sp-2, $sp-2); }
.corner--bl { bottom: $sp-2; left: $sp-2; border-right: none; border-top: none; transform: translate($sp-2, -$sp-2); }
.corner--br { bottom: $sp-2; right: $sp-2; border-left: none; border-top: none; transform: translate(-$sp-2, -$sp-2); }
.card:hover .corner,
.card:focus-visible .corner,
.card:active .corner {
opacity: 1;
transform: translate(0, 0);
}
.card.is-demo .corner { animation: frame-loop $dur-loop $easing infinite; }
@keyframes frame-loop {
0%, 15% { opacity: 0; }
45%, 75% { opacity: 1; transform: translate(0, 0); }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.corner { transition: none; }
.card.is-demo .corner { animation: none; opacity: 1; transform: translate(0, 0); }
}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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
background: #17141a;
outline: none;
cursor: pointer;
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.card__body {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px 12px;
}
.card__title {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.corner {
position: absolute;
width: 16px;
height: 16px;
border: 2px solid #2f6df6;
opacity: 0;
z-index: 2;
transition: transform 220ms ease-out, opacity 220ms ease-out;
}
.corner--tl {
top: 8px;
left: 8px;
border-right: none;
border-bottom: none;
transform: translate(8px, 8px);
}
.corner--tr {
top: 8px;
right: 8px;
border-left: none;
border-bottom: none;
transform: translate(-8px, 8px);
}
.corner--bl {
bottom: 8px;
left: 8px;
border-right: none;
border-top: none;
transform: translate(8px, -8px);
}
.corner--br {
bottom: 8px;
right: 8px;
border-left: none;
border-top: none;
transform: translate(-8px, -8px);
}
.card:hover .corner,
.card:focus-visible .corner,
.card:active .corner {
opacity: 1;
transform: translate(0, 0);
}
.card.is-demo .corner {
animation: frame-loop 2s ease-out infinite;
}
@keyframes frame-loop {
0%, 15% {
opacity: 0;
}
45%, 75% {
opacity: 1;
transform: translate(0, 0);
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.corner {
transition: none;
}
.card.is-demo .corner {
animation: none;
opacity: 1;
transform: translate(0, 0);
}
}import type { CSSProperties } from "react";
import styles from "./CornerFrameCard.module.scss";
export type CornerFrameCardProps = {
title: string;
/** 브래킷이 자라는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 브래킷 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CornerFrameCard({
title,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: CornerFrameCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<span className={`${styles.corner} ${styles.tl}`} aria-hidden="true" />
<span className={`${styles.corner} ${styles.tr}`} aria-hidden="true" />
<span className={`${styles.corner} ${styles.bl}`} aria-hidden="true" />
<span className={`${styles.corner} ${styles.br}`} aria-hidden="true" />
<span className={styles.media} aria-hidden="true" />
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
</div>
</article>
);
}04 · 모서리 브래킷 프레임 — 측정 누적면적 0.773% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
갤러리 그리드에
여러 장 중 하나를 고르는 갤러리 그리드에 어울립니다.
.corner {
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.corner--tl { transform: translate($sp-2, $sp-2); }
.card:hover .corner,
.card:focus-visible .corner {
opacity: 1;
transform: translate(0, 0);
}
05뱃지 탄성 팝인
튕기는 이징으로 자리 잡는다
평소엔 숨어 있던 'NEW'·'SALE' 뱃지가 호버하면 튕기는 이징으로 순간 커졌다가 살짝 줄며 자리를 잡습니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="badge" aria-hidden="true">NEW</span>
<span class="card__media" aria-hidden="true"></span>
<div class="card__body">
<h3 class="card__title">무선 키보드</h3>
<p class="card__meta">89,000원</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 뱃지가 튀는 시간
$easing: $ease-pop;
$color: $subject-blue; // 뱃지 배경
@include stage($stage-ink);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
background: $stage-paper;
box-shadow: $shadow-press;
outline: none;
cursor: pointer;
}
.card__media {
position: absolute; inset: 0;
background: linear-gradient(155deg, $color 0%, $stage-ink 100%);
}
.card__body {
position: absolute; left: 0; right: 0; bottom: 0;
padding: $sp-2 $sp-3;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.3; font-family: inherit; color: rgba($stage-paper, .85); }
.badge {
position: absolute;
top: $sp-2; right: $sp-2;
z-index: 2;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $color;
color: $stage-ink;
font-weight: 700; font-size: 11px; line-height: 1; font-family: inherit;
transform: scale(0);
transition: transform $duration $easing;
}
.card:hover .badge,
.card:focus-visible .badge,
.card:active .badge {
transform: scale(1);
}
.card.is-demo .badge { animation: badge-loop $dur-loop $easing infinite; }
@keyframes badge-loop {
0%, 15% { transform: scale(0); }
35% { transform: scale(1.25); }
50% { transform: scale(.92); }
65%, 80% { transform: scale(1); }
100% { transform: scale(0); }
}
@media (prefers-reduced-motion: reduce) {
.badge { transition: none; }
.card.is-demo .badge { animation: none; transform: scale(1); }
}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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.card {
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.card__body {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px 12px;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.85);
}
.badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 4px 8px;
border-radius: 999px;
background: #2f6df6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1;
font-family: inherit;
transform: scale(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.card:hover .badge,
.card:focus-visible .badge,
.card:active .badge {
transform: scale(1);
}
.card.is-demo .badge {
animation: badge-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes badge-loop {
0%, 15% {
transform: scale(0);
}
35% {
transform: scale(1.25);
}
50% {
transform: scale(0.92);
}
65%, 80% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@media (prefers-reduced-motion: reduce) {
.badge {
transition: none;
}
.card.is-demo .badge {
animation: none;
transform: scale(1);
}
}import type { CSSProperties } from "react";
import styles from "./BadgePopCard.module.scss";
export type BadgePopCardProps = {
title: string;
meta: string;
badge: string;
/** 뱃지가 튀는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 뱃지 배경 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BadgePopCard({
title,
meta,
badge,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: BadgePopCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<span className={styles.badge} aria-hidden="true">{badge}</span>
<span className={styles.media} aria-hidden="true" />
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}05 · 뱃지 탄성 팝인 — 측정 누적면적 0.917% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
한 번 더 끌어야 할 때
할인 상품이나 신규 항목처럼 시선을 한 번 더 끌어야 하는 카드에 씁니다.
.badge {
transform: scale(0);
transition: transform $duration $ease-pop;
}
.card:hover .badge,
.card:focus-visible .badge {
transform: scale(1);
}
06CTA 밑줄 스윕
카드 어디에 올려도 그려진다
'자세히 보기' 글자 자체에 올리지 않고 카드 어디에 올려도, 부모 .card:hover가 자식 밑줄을 골라 왼쪽에서 오른쪽으로 그립니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<div class="card__body">
<h3 class="card__title">브랜드 스토리</h3>
<p class="card__meta">우리가 만드는 이유</p>
<span class="cta">
<span class="cta__label">자세히 보기</span>
<span class="cta__underline" aria-hidden="true"></span>
</span>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 밑줄이 그려지는 시간
$easing: $ease-out;
$color: $subject-blue; // CTA 색
@include stage($stage-yellow);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
width: min(160px, 50vw);
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
padding: $sp-3;
box-sizing: border-box;
outline: none;
cursor: pointer;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .6); }
.cta {
position: relative;
display: inline-block;
margin-top: $sp-2;
font-weight: 700; font-size: 12px; line-height: 1.3; font-family: inherit;
color: $color;
}
.cta__underline {
position: absolute;
left: 0; right: 0; bottom: -2px;
height: 2px;
background: $color;
transform: scaleX(0);
transform-origin: left; // 규칙8: 좌변서 그어짐
transition: transform $duration $easing;
}
// 밑줄 자체가 아니라 카드 어디에 올려도 부모 :hover 가 자식 밑줄을 켠다
.card:hover .cta__underline,
.card:focus-visible .cta__underline,
.card:active .cta__underline {
transform: scaleX(1);
}
.card.is-demo .cta__underline { animation: underline-loop $dur-loop $easing infinite; }
@keyframes underline-loop {
0%, 15% { transform: scaleX(0); }
45%, 75% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
.cta__underline { transition: none; }
.card.is-demo .cta__underline { animation: none; transform: scaleX(1); }
}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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.card {
width: min(160px, 50vw);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
padding: 12px;
box-sizing: border-box;
outline: none;
cursor: pointer;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.cta {
position: relative;
display: inline-block;
margin-top: 8px;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
font-family: inherit;
color: #2f6df6;
}
.cta__underline {
position: absolute;
left: 0;
right: 0;
bottom: -2px;
height: 2px;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left;
transition: transform 300ms ease-out;
}
.card:hover .cta__underline,
.card:focus-visible .cta__underline,
.card:active .cta__underline {
transform: scaleX(1);
}
.card.is-demo .cta__underline {
animation: underline-loop 2s ease-out infinite;
}
@keyframes underline-loop {
0%, 15% {
transform: scaleX(0);
}
45%, 75% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.cta__underline {
transition: none;
}
.card.is-demo .cta__underline {
animation: none;
transform: scaleX(1);
}
}import type { CSSProperties } from "react";
import styles from "./CtaUnderlineCard.module.scss";
export type CtaUnderlineCardProps = {
title: string;
meta: string;
cta: string;
/** 밑줄이 그려지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** CTA 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 밑줄 자체가 아니라 카드 어디에 올려도 부모 :hover 가 자식 밑줄을 켠다 */
export default function CtaUnderlineCard({
title,
meta,
cta,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: CtaUnderlineCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
<span className={styles.cta}>
<span>{cta}</span>
<span className={styles.underline} aria-hidden="true" />
</span>
</article>
);
}06 · CTA 밑줄 스윕 (부모 호버) — 측정 누적면적 0.082% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
클릭 범위 전체가 신호
블로그 카드의 더 보기 링크나 랜딩 페이지 CTA에 붙이면 클릭 범위 전체가 신호를 보냅니다.
.cta__underline {
transform: scaleX(0);
transform-origin: left;
transition: transform $duration $easing;
}
.card:hover .cta__underline,
.card:focus-visible .cta__underline {
transform: scaleX(1);
}
07앵커 위치 미니 패널
앵커로 붙는 미니 패널
카드에 anchor-name을 붙이고 CSS 앵커 포지셔닝으로 그 옆(또는 아래)에 미니 정보 패널을 띄우면, 호버했을 때 패널이 카드에 자동으로 붙어 나타납니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="card__media" aria-hidden="true"></span>
<div class="card__body">
<h3 class="card__title">무선 이어폰 프로</h3>
<p class="card__meta">128,000원</p>
</div>
</article>
<div class="panel" aria-hidden="true">
<p class="panel__row">재고 12개</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 패널이 뜨는 시간
$easing: $ease-out;
$color: $subject-blue; // 미디어 그라디언트
@include stage($stage-orange);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
anchor-name: --c7;
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
overflow: hidden;
background: $stage-paper;
box-shadow: $shadow-press;
outline: none;
cursor: pointer;
}
.card__media {
position: absolute; inset: 0;
background: linear-gradient(155deg, $color 0%, $stage-ink 100%);
}
.card__body {
position: absolute; left: 0; right: 0; bottom: 0;
padding: $sp-2 $sp-3;
background: linear-gradient(0deg, rgba($stage-ink, .8), rgba($stage-ink, 0));
}
.card__title { margin: 0; font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.3; font-family: inherit; color: $stage-paper; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.3; font-family: inherit; color: rgba($stage-paper, .85); }
// CSS 앵커 포지셔닝 — 카드가 어디 있든 anchor-name 만 맞으면 패널이 그 아래에 자동으로 붙는다
.panel {
position: absolute;
position-anchor: --c7;
position-area: block-end;
position-try-fallbacks: flip-block;
margin-top: $sp-1;
width: min(150px, 48vw);
box-sizing: border-box;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: $stage-ink;
color: $stage-paper;
font-weight: 500; font-size: 11px; line-height: 1.4; font-family: inherit;
white-space: nowrap;
text-align: center;
opacity: 0;
transform: translateY(-6px);
transition: opacity $duration $easing, transform $duration $easing;
pointer-events: none;
z-index: 5;
}
.panel__row { margin: 0; }
.card:hover ~ .panel,
.card:focus-visible ~ .panel {
opacity: 1;
transform: translateY(0);
}
.card.is-demo ~ .panel { animation: panel-loop $dur-loop $easing infinite; }
@keyframes panel-loop {
0%, 20% { opacity: 0; transform: translateY(-6px); }
50%, 75% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
.panel { transition: none; }
.card.is-demo ~ .panel { animation: none; opacity: 1; transform: translateY(0); }
}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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.card {
anchor-name: --c7;
position: relative;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
}
.card__media {
position: absolute;
inset: 0;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.card__body {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px 12px;
background: linear-gradient(0deg, rgba(23, 20, 26, 0.8), rgba(23, 20, 26, 0));
}
.card__title {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #ffffff;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.85);
}
.panel {
position: absolute;
position-anchor: --c7;
position-area: block-end;
position-try-fallbacks: flip-block;
margin-top: 4px;
width: min(150px, 48vw);
box-sizing: border-box;
padding: 4px 8px;
border-radius: 8px;
background: #17141a;
color: #ffffff;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
white-space: nowrap;
text-align: center;
opacity: 0;
transform: translateY(-6px);
transition: opacity 300ms ease-out, transform 300ms ease-out;
pointer-events: none;
z-index: 5;
}
.panel__row {
margin: 0;
}
.card:hover ~ .panel,
.card:focus-visible ~ .panel {
opacity: 1;
transform: translateY(0);
}
.card.is-demo ~ .panel {
animation: panel-loop 2s ease-out infinite;
}
@keyframes panel-loop {
0%, 20% {
opacity: 0;
transform: translateY(-6px);
}
50%, 75% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-6px);
}
}
@media (prefers-reduced-motion: reduce) {
.panel {
transition: none;
}
.card.is-demo ~ .panel {
animation: none;
opacity: 1;
transform: translateY(0);
}
}import type { CSSProperties } from "react";
import styles from "./AnchoredPanelCard.module.scss";
export type AnchoredPanelCardProps = {
title: string;
meta: string;
panel: string;
/** 패널이 뜨는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 미디어 그라디언트 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** CSS 앵커 포지셔닝 — anchor-name 만 맞으면 패널이 카드 아래에 자동으로 붙는다 (JS 없음) */
export default function AnchoredPanelCard({
title,
meta,
panel,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: AnchoredPanelCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.wrap} style={vars}>
<article className={styles.card} tabIndex={0}>
<span className={styles.media} aria-hidden="true" />
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
<div className={styles.panel} aria-hidden="true">
<p className={styles.panelRow}>{panel}</p>
</div>
</div>
);
}07 · 앵커 위치 미니 패널 — 측정 누적면적 2.903% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
재고와 스펙을 먼저
재고·스펙을 미리 보여주는 상품 목록에 맞습니다.
.card { anchor-name: --c7; }
.panel {
position: absolute;
position-anchor: --c7;
position-area: block-end;
position-try-fallbacks: flip-block;
opacity: 0;
transition: opacity $duration $easing, transform $duration $easing;
}
.card:hover ~ .panel { opacity: 1; transform: translateY(0); }
08글래스모피즘 블러
호버하면 유리가 밝아진다
반투명 카드 배경에 backdrop-filter: blur()가 걸려 있다가, 호버하면 블러와 채도가 더 짙어지며 유리 표면이 빛을 받은 것처럼 밝아집니다.

<main class="stage stage--glass">
<span class="bg-blob" aria-hidden="true"></span>
<article class="card is-demo" tabindex="0">
<div class="card__body">
<h3 class="card__title">프리미엄 위젯</h3>
<p class="card__meta">실시간 통계</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 블러·채도가 짙어지는 시간
$easing: $ease-out;
$color: $error; // 배경 블롭 강조색
@include stage($stage-paper);
.stage { padding: clamp(8px, 2.4vw, 16px); position: relative; overflow: hidden; }
.bg-blob {
position: absolute;
inset: -20%;
background:
radial-gradient(circle at 30% 30%, $subject-blue, transparent 60%),
radial-gradient(circle at 70% 70%, $color, transparent 60%);
}
.card {
position: relative;
z-index: 1;
width: min(150px, 48vw);
aspect-ratio: 4 / 3;
border-radius: $r-card;
padding: $sp-3;
box-sizing: border-box;
background: rgba(255, 255, 255, .14);
border: 1px solid rgba(255, 255, 255, .3);
backdrop-filter: blur(6px) saturate(120%);
-webkit-backdrop-filter: blur(6px) saturate(120%);
box-shadow: $shadow-press;
outline: none;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
transform: translateY(0);
transition: backdrop-filter $duration $easing, background $duration $easing, box-shadow $duration $easing, transform $duration $easing;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .85); }
.card:hover,
.card:focus-visible,
.card:active {
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
background: rgba(255, 255, 255, .26);
box-shadow: $shadow-raised;
transform: translateY(-4px);
}
.card.is-demo { animation: glass-loop $dur-loop $easing infinite; }
@keyframes glass-loop {
0%, 15% {
backdrop-filter: blur(6px) saturate(120%);
background: rgba(255, 255, 255, .14);
box-shadow: $shadow-press;
transform: translateY(0);
}
45%, 75% {
backdrop-filter: blur(14px) saturate(160%);
background: rgba(255, 255, 255, .26);
box-shadow: $shadow-raised;
transform: translateY(-4px);
}
100% {
backdrop-filter: blur(6px) saturate(120%);
background: rgba(255, 255, 255, .14);
box-shadow: $shadow-press;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
.card.is-demo { animation: none; backdrop-filter: blur(14px) saturate(160%); background: rgba(255, 255, 255, .26); 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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
position: relative;
overflow: hidden;
}
.bg-blob {
position: absolute;
inset: -20%;
background: radial-gradient(circle at 30% 30%, #2f6df6, transparent 60%), radial-gradient(circle at 70% 70%, #c2361a, transparent 60%);
}
.card {
position: relative;
z-index: 1;
width: min(150px, 48vw);
aspect-ratio: 4/3;
border-radius: 16px;
padding: 12px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(6px) saturate(120%);
-webkit-backdrop-filter: blur(6px) saturate(120%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
transform: translateY(0);
transition: backdrop-filter 300ms ease-out, background 300ms ease-out, box-shadow 300ms ease-out, transform 300ms ease-out;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.85);
}
.card:hover,
.card:focus-visible,
.card:active {
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
background: rgba(255, 255, 255, 0.26);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateY(-4px);
}
.card.is-demo {
animation: glass-loop 2s ease-out infinite;
}
@keyframes glass-loop {
0%, 15% {
backdrop-filter: blur(6px) saturate(120%);
background: rgba(255, 255, 255, 0.14);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(0);
}
45%, 75% {
backdrop-filter: blur(14px) saturate(160%);
background: rgba(255, 255, 255, 0.26);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateY(-4px);
}
100% {
backdrop-filter: blur(6px) saturate(120%);
background: rgba(255, 255, 255, 0.14);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card.is-demo {
animation: none;
backdrop-filter: blur(14px) saturate(160%);
background: rgba(255, 255, 255, 0.26);
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./GlassCard.module.scss";
export type GlassCardProps = {
title: string;
meta: string;
/** 블러·채도가 짙어지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 배경 블롭 강조색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GlassCard({
title,
meta,
duration = 300,
easing = "ease-out",
color = "#c2361a",
}: GlassCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<span className={styles.blob} aria-hidden="true" />
<article className={styles.card} tabIndex={0}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</article>
</div>
);
}08 · 글래스모피즘 블러 — 측정 누적면적 12.276% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
다크 배경 위에서
다크 배경 위 프리미엄 카드나 대시보드 위젯에 씁니다.
.card {
backdrop-filter: blur(6px) saturate(120%);
transition: backdrop-filter $duration $easing, transform $duration $easing;
}
.card:hover {
backdrop-filter: blur(14px) saturate(160%);
transform: translateY(-4px);
}
09선택 링 드로잉
링이 한 바퀴 그려진다
고를 수 있는 카드를 호버하거나 선택하면 테두리를 감싼 SVG 링이 stroke-dashoffset으로 한 바퀴 그려지며 '선택됨'을 알립니다.

<main class="stage">
<article class="card is-demo" tabindex="0">
<svg class="ring" viewBox="0 0 160 100" aria-hidden="true">
<rect class="ring__rect" x="2" y="2" width="156" height="96" rx="14" ry="14"></rect>
</svg>
<div class="card__body">
<h3 class="card__title">베이직 플랜</h3>
<p class="card__meta">월 4,900원</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 링이 그려지는 시간
$easing: $ease-spring;
$color: $subject-blue; // 링 색
@include stage($stage-ink);
.stage { padding: clamp(8px, 2.4vw, 16px); }
.card {
position: relative;
width: min(160px, 50vw);
aspect-ratio: 8 / 5;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
padding: $sp-3;
box-sizing: border-box;
outline: none;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
}
.ring {
position: absolute; inset: 0;
width: 100%; height: 100%;
pointer-events: none;
}
.ring__rect {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 500;
stroke-dashoffset: 500;
transition: stroke-dashoffset $duration $easing;
}
.card__title { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .6); }
.card:hover .ring__rect,
.card:focus-visible .ring__rect,
.card:active .ring__rect {
stroke-dashoffset: 0;
}
.card.is-demo .ring__rect { animation: ring-loop $dur-loop $easing infinite; }
@keyframes ring-loop {
0%, 10% { stroke-dashoffset: 500; }
55%, 80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 500; }
}
@media (prefers-reduced-motion: reduce) {
.ring__rect { transition: none; }
.card.is-demo .ring__rect { animation: none; stroke-dashoffset: 0; }
}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);
}
.stage {
padding: clamp(8px, 2.4vw, 16px);
}
.card {
position: relative;
width: min(160px, 50vw);
aspect-ratio: 8/5;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
padding: 12px;
box-sizing: border-box;
outline: none;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
}
.ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.ring__rect {
fill: none;
stroke: #2f6df6;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 500;
stroke-dashoffset: 500;
transition: stroke-dashoffset 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.card:hover .ring__rect,
.card:focus-visible .ring__rect,
.card:active .ring__rect {
stroke-dashoffset: 0;
}
.card.is-demo .ring__rect {
animation: ring-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ring-loop {
0%, 10% {
stroke-dashoffset: 500;
}
55%, 80% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 500;
}
}
@media (prefers-reduced-motion: reduce) {
.ring__rect {
transition: none;
}
.card.is-demo .ring__rect {
animation: none;
stroke-dashoffset: 0;
}
}import type { CSSProperties } from "react";
import styles from "./SelectRingCard.module.scss";
export type SelectRingCardProps = {
title: string;
meta: string;
/** 링이 그려지는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 링 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SelectRingCard({
title,
meta,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SelectRingCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<svg className={styles.ring} viewBox="0 0 160 100" aria-hidden="true">
<rect className={styles.ringRect} x={2} y={2} width={156} height={96} rx={14} ry={14} />
</svg>
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}09 · 선택 링 드로잉 — 측정 누적면적 1.335% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
선택을 확정하는 동작
플랜 선택이나 라디오형 카드 선택 UI에 맞는 마무리 동작입니다.
.ring__rect {
stroke-dasharray: 500;
stroke-dashoffset: 500;
transition: stroke-dashoffset $duration $easing;
}
.card:hover .ring__rect,
.card:focus-visible .ring__rect {
stroke-dashoffset: 0;
}
어디서 깨지는가 — 함정 하나
강도 0.0 이 나온 이유
08 글래스모피즘을 처음 렌더링했을 때 측정값이 이상했습니다. 카드 배경 전체가 눈에 보이게 흐려지는데도 강도_평균이 0.0, 움직인 프레임이 23장 중 0장으로 나왔습니다. 렌더는 실제 재생이 아니라 Web Animations API로 currentTime을 프레임마다 강제로 옮기며 스크린샷을 찍는 방식인데, 헤드리스 크롬이 backdrop-filter만 바뀌는 합성 레이어를 이 방식으로는 프레임 사이사이마다 제대로 다시 칠하지 않는 것으로 보였습니다.
transform 하나를 더했다
같은 keyframes 안에 transform: translateY(-4px) 하나만 더 넣어 블러와 함께 카드가 살짝 떠오르게 했더니, 같은 조건에서 강도_평균 53.9·움직인 프레임 12장으로 정상 측정됐습니다. 압축 파일 비밀번호는 tvyct8zb이고, 이 기록이 없었다면 08은 "안 움직이는 카드"로 계속 남아 있었을 겁니다. 블러·채도처럼 합성 레이어에만 걸리는 값은 transform이나 opacity를 곁들여야 측정도 되고 사람 눈에도 또렷합니다. 질감을 두껍게 쌓는 스큐어모피즘 질감 부품도 그림자와 광택만 움직이면 08과 똑같이 측정에서 사라집니다.
접근성
전환만 끄고 마지막 상태는 남는다
아홉 개 전부 prefers-reduced-motion: reduce에서 전환·애니메이션만 끄고 마지막 상태는 남깁니다. 01은 캡션이 늘 펼쳐진 채 이미지 확대만 순간적으로 전환되고, 02는 기울기가 전환 없이 순간적으로 적용되고, 03은 뒤집힘이 순간적으로 일어나고, 04는 브래킷이 즉시 켜지고 꺼지고, 05는 뱃지가 튀지 않고 바로 자리를 잡고, 06은 밑줄이 즉시 그어지고, 07은 패널이 순간적으로 뜨고 사라지고, 08은 블러 단계 전환 없이 고정되고, 09는 링이 순간에 완성됩니다.
세 상태를 한 규칙에
마우스로 올렸을 때뿐 아니라 키보드로 넘어왔을 때와 손끝으로 눌렀을 때도 똑같은 모습이 나오도록 세 상태를 한 규칙에 묶어 뒀습니다.
지원 범위와 더 읽을 거리
:has() 지원 범위는 MDN 문서에서, CSS 앵커 포지셔닝 지원 범위는 caniuse에서 확인됩니다. 다른 카드 기법이 궁금하면 이전에 올린 카드 호버 모음(여기)과 글래스모피즘 모음(여기)도 함께 보면 좋습니다.
FAQ
호버 카드의 구역별 틸트는 :has() 미지원 브라우저에서 어떻게 됩니까?
:has()가 없으면 그 셀렉터를 포함한 규칙 자체가 매치되지 않아 카드가 그냥 평평한 상태로 멈춥니다. 레이아웃이 깨지거나 에러가 나지 않는 안전한 성능 저하이고, 최신 Chrome·Safari·Firefox는 이미 지원합니다.
07 앵커 위치 패널은 모든 브라우저에서 똑같이 뜹니까?
Chrome·Edge는 뜨지만 이 글을 쓰는 시점의 Firefox·Safari는 CSS 앵커 포지셔닝을 지원하지 않아 패널이 아예 나타나지 않습니다. 지원하지 않는 브라우저에서도 카드 자체(이미지·제목·가격)는 정상적으로 보이므로 핵심 정보 손실은 없습니다.
React 판에서 09 선택 링은 상태를 어떻게 관리합니까?
SelectRingCard 컴포넌트는 별도의 state 없이 CSS의 :hover·:focus-visible·:active만으로 링을 그립니다. 실제 폼 선택처럼 클릭 후에도 계속 선택된 상태를 남기고 싶다면 부모 컴포넌트에서 aria-pressed와 함께 별도 클래스를 하나 더 얹으면 됩니다. 커서 좌표를 직접 읽어 반응시키는 쪽은 마우스 이펙트 9가지에서 따로 다뤘습니다.









