02Zone-based 3D tilt (CSS only)
The card is split into four invisible zones, and a :has() selector picks a different tilt value for whichever zone the pointer is over, tilting the card with zero JS coordinate tracking.
Best forFeature highlight cards, portfolio cards where tilt needs to work without JS
:has() 선택자rotateX/rotateYperspective

<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">New release</h3>
<p class="card__meta">View Q3 roadmap</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 Zone-based 3D tilt (CSS only) — Measured area 2.811% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Card Hover Effects You Can Copy-Paste
The post this demo lives in
9 CSS Card Hover Effects You Can Copy-PasteCSS card hover effects are the short changes — a tilt, a flip, a badge popping in — that a card plays on hover, ready to See all demos in this post


