02네오브루탈리즘 오프셋
굵은 검은 테두리와 오른쪽 아래로 어긋난 단색 그림자가 카드를 종이처럼 띄워 두고, 카드가 그림자 자리로 내려앉았다 올라오며 눌리는 느낌을 만듭니다.
쓰임인디 제품·포트폴리오처럼 눈에 확 띄는 대비와 장난기 있는 인상이 필요한 소개 화면
box-shadowtranslatearia-pressed

<main class="stage">
<div class="nb is-demo">
<article class="nb__card">
<div class="nb__head">
<div class="nb__avatar">DH</div>
<div class="nb__who">
<p class="nb__name">박도현</p>
<p class="nb__role">프론트엔드 개발자</p>
</div>
</div>
<div class="nb__stats">
<div class="nb__stat"><strong class="nb__num">1,842</strong><span class="nb__lab">팔로워</span></div>
<div class="nb__stat"><strong class="nb__num">36</strong><span class="nb__lab">게시물</span></div>
</div>
<button class="nb__btn" type="button" id="nb-btn" aria-pressed="false">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" hidden><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span id="nb-label">팔로우</span>
</button>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
// 네오브루탈리즘 — 3px 잉크 테두리 + 오프셋 단색 그림자. 누르면 그림자 자리로 내려앉는다.
.nb {
position: relative;
width: 308px; // 카드 300 + 그림자 8 (눌림 여유)
height: 204px; // 카드 196 + 8
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.nb__card {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-3;
width: 300px;
height: 196px;
padding: 0 $sp-4;
background: #fff;
border: 3px solid $subject-ink;
border-radius: $r-card;
box-shadow: 8px 8px 0 $subject-ink;
color: $subject-ink;
}
.nb__head { display: flex; align-items: center; gap: $sp-3; }
.nb__avatar {
display: flex; align-items: center; justify-content: center;
box-sizing: border-box;
width: 56px; height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, $subject-sky, $subject-blue);
border: 3px solid $subject-ink;
color: $subject-ink;
font-size: 17px; line-height: 21px; font-weight: 800;
}
.nb__who { display: flex; flex-direction: column; align-items: flex-start; gap: $sp-1; }
.nb__name { margin: 0; font-size: 20px; line-height: 24px; font-weight: 800; }
.nb__role {
margin: 0;
display: inline-flex; align-items: center;
padding: $sp-1 $sp-2;
background: $color;
border-radius: $r-pill;
color: #fff;
font-size: 11px; line-height: 13px; font-weight: 700;
}
.nb__stats { display: flex; gap: $sp-2; }
.nb__stat {
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2;
background: $stage-yellow;
border: 3px solid $subject-ink;
border-radius: $r-sm;
}
.nb__num { font-size: 17px; line-height: 20px; font-weight: 800; font-variant-numeric: tabular-nums; }
.nb__lab { font-size: 11px; line-height: 13px; font-weight: 700; }
.nb__btn {
display: inline-flex; align-items: center; justify-content: center;
gap: $sp-1;
height: 36px;
padding: 0 $sp-4;
background: $subject-ink;
border: 3px solid $subject-ink;
border-radius: $r-control;
box-shadow: 4px 4px 0 $subject-ink;
color: #fff;
font-size: 13px; line-height: 16px; font-weight: 700;
cursor: pointer;
transition: transform $duration $easing, box-shadow $duration $easing, background $duration $easing;
}
.nb__btn svg { display: block; }
.nb__btn svg[hidden] { display: none; }
.nb__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.nb__btn[aria-pressed="true"] { background: $color; }
.nb__card:active { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
.nb__btn:active { transform: translate(4px, 4px); box-shadow: 0 0 0 $subject-ink; }
// ── 자동 루프: 카드가 그림자 자리로 내려앉았다 올라온다(두 번). 복귀는 68% 안에 끝 — 끝 프레임 = 첫 프레임 ──
.nb.is-demo .nb__card { animation: nb-press $dur-loop $ease-out infinite; }
@keyframes nb-press {
0%, 100% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
9% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
20% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
46% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
55% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
68% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
}
@media (max-width: 340px) {
.nb { width: 280px; height: 158px; }
.nb__card { width: 272px; height: 150px; gap: $sp-1; padding: 0 $sp-3; }
.nb__head { gap: $sp-2; }
.nb__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 17px; }
.nb__who { gap: 0; }
.nb__name { font-size: 14px; line-height: 18px; }
.nb__stats { gap: $sp-1; }
.nb__stat { gap: 0; padding: $sp-1; }
.nb__num { font-size: 13px; line-height: 16px; }
.nb__lab { font-size: 11px; line-height: 13px; }
.nb__btn { height: 32px; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.nb.is-demo .nb__card { animation: none !important; }
.nb__card, .nb__btn { transition: 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);
}
.nb {
position: relative;
width: 308px;
height: 204px;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.nb__card {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 300px;
height: 196px;
padding: 0 16px;
background: #fff;
border: 3px solid #17141a;
border-radius: 16px;
box-shadow: 8px 8px 0 #17141a;
color: #17141a;
}
.nb__head {
display: flex;
align-items: center;
gap: 12px;
}
.nb__avatar {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #6ec8ff, #2f6df6);
border: 3px solid #17141a;
color: #17141a;
font-size: 17px;
line-height: 21px;
font-weight: 800;
}
.nb__who {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.nb__name {
margin: 0;
font-size: 20px;
line-height: 24px;
font-weight: 800;
}
.nb__role {
margin: 0;
display: inline-flex;
align-items: center;
padding: 4px 8px;
background: #2f6df6;
border-radius: 999px;
color: #fff;
font-size: 11px;
line-height: 13px;
font-weight: 700;
}
.nb__stats {
display: flex;
gap: 8px;
}
.nb__stat {
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
background: #ffd23f;
border: 3px solid #17141a;
border-radius: 8px;
}
.nb__num {
font-size: 17px;
line-height: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.nb__lab {
font-size: 11px;
line-height: 13px;
font-weight: 700;
}
.nb__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 16px;
background: #17141a;
border: 3px solid #17141a;
border-radius: 12px;
box-shadow: 4px 4px 0 #17141a;
color: #fff;
font-size: 13px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
transition: transform 220ms ease-out, box-shadow 220ms ease-out, background 220ms ease-out;
}
.nb__btn svg {
display: block;
}
.nb__btn svg[hidden] {
display: none;
}
.nb__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.nb__btn[aria-pressed=true] {
background: #2f6df6;
}
.nb__card:active {
transform: translate(8px, 8px);
box-shadow: 0 0 0 #17141a;
}
.nb__btn:active {
transform: translate(4px, 4px);
box-shadow: 0 0 0 #17141a;
}
.nb.is-demo .nb__card {
animation: nb-press 2s ease-out infinite;
}
@keyframes nb-press {
0%, 100% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
9% {
transform: translate(8px, 8px);
box-shadow: 0 0 0 #17141a;
}
20% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
46% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
55% {
transform: translate(8px, 8px);
box-shadow: 0 0 0 #17141a;
}
68% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
}
@media (max-width: 340px) {
.nb {
width: 280px;
height: 158px;
}
.nb__card {
width: 272px;
height: 150px;
gap: 4px;
padding: 0 12px;
}
.nb__head {
gap: 8px;
}
.nb__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 17px;
}
.nb__who {
gap: 0;
}
.nb__name {
font-size: 14px;
line-height: 18px;
}
.nb__stats {
gap: 4px;
}
.nb__stat {
gap: 0;
padding: 4px;
}
.nb__num {
font-size: 13px;
line-height: 16px;
}
.nb__lab {
font-size: 11px;
line-height: 13px;
}
.nb__btn {
height: 32px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.nb.is-demo .nb__card {
animation: none !important;
}
.nb__card, .nb__btn {
transition: none;
}
}// 실제 동작: 팔로우 버튼 토글. 첫 조작에 자동 루프가 끊기고 :active 눌림이 그대로 살아 있다.
var root = document.querySelector('.nb');
var btn = document.getElementById('nb-btn');
var label = document.getElementById('nb-label');
var icons = btn.querySelectorAll('svg');
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', String(on));
icons[0].hidden = on;
icons[1].hidden = !on;
label.textContent = on ? '팔로잉' : '팔로우';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./NeoBrutalCard.module.scss";
export type NeoBrutalCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconFollow = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function NeoBrutalCard({
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: NeoBrutalCardProps) {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(e: ClickEvt): void {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
}
return (
<div className={styles.nb}>
<article className={styles.card}>
<div className={styles.head}>
<div className={styles.avatar}>DH</div>
<div className={styles.who}>
<p className={styles.name}>박도현</p>
<p className={styles.role}>프론트엔드 개발자</p>
</div>
</div>
<div className={styles.stats}>
<div className={styles.stat}><strong className={styles.num}>1,842</strong><span className={styles.lab}>팔로워</span></div>
<div className={styles.stat}><strong className={styles.num}>36</strong><span className={styles.lab}>게시물</span></div>
</div>
<button type="button" className={styles.btn} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconFollow />}
<span>{on ? "팔로잉" : "팔로우"}</span>
</button>
</article>
</div>
);
}02 네오브루탈리즘 오프셋 — 측정 누적면적 18.2% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 프로필 카드 디자인 9종 — 복붙하면 되는 스타일 모음
이 데모가 속한 글
프로필 카드 디자인 9종 — 복붙하면 되는 스타일 모음프로필 카드 디자인은 아바타·이름·직함·소셜 스탯·팔로우 버튼이라는 한 덩어리를 어떤 재질로 보여줄지 정하는 일입니다. 아홉 개는 그 안쪽 구조가 전부 같고, 바깥 재질과 움직임만 다릅니다. 이 글의 데모 전부 보기


