04미니멀 라인
1px 선과 넓은 여백만 남긴 카드에서 이름 아래 밑줄이 왼쪽부터 그어지고, 팔로우 버튼 테두리도 같은 방향으로 채워집니다.
쓰임금융·법률·B2B SaaS처럼 신뢰가 먼저인 밝은 화면의 담당자 카드
scaleXtransform-originaria-pressed

<main class="stage">
<div class="ml is-demo">
<article class="ml__card">
<div class="ml__avatar" aria-hidden="true">YW</div>
<h2 class="ml__name">최연우<span class="ml__line ml__line--name" aria-hidden="true"></span></h2>
<p class="ml__role">브랜드 전략가</p>
<p class="ml__stats">
<span class="ml__stat"><strong>812</strong>팔로워</span>
<span class="ml__stat"><strong>41</strong>게시물</span>
</p>
<button class="ml__follow" type="button" id="ml-follow" aria-pressed="false">
<svg class="ml__i ml__i--plus" 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 class="ml__i ml__i--check" 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>
<span class="ml__label" id="ml-label">팔로우</span>
<span class="ml__line ml__line--btn" aria-hidden="true"></span>
</button>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.ml {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
// 흰 무대 위 흰 카드 — 1px 선과 여백만으로 형태를 만든다
.ml__card {
display: flex;
flex-direction: column;
align-items: center;
width: 300px;
padding: $sp-4;
background: #fff;
border: 1px solid rgba(23, 20, 26, .14);
border-radius: $r-card;
}
.ml__avatar {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border: 1px solid rgba(23, 20, 26, .22);
border-radius: 50%;
background: rgba($subject-blue, .08);
color: $subject-ink;
font-size: 17px;
line-height: 20px;
font-weight: 700;
}
.ml__name {
margin: $sp-2 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: $subject-ink;
text-align: center;
}
.ml__role {
margin: $sp-1 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(23, 20, 26, .64);
}
// 밑줄 — 왼쪽부터 scaleX 로 그어지고, 왼쪽으로 접혀 돌아간다 (origin 고정)
.ml__line {
display: block;
height: 2px;
background: $subject-ink;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 밑줄 left에서 그어짐
transition: transform $duration $easing;
}
.ml__line--name { width: 56px; margin: $sp-1 auto 0; }
.ml__line--btn {
position: absolute;
left: -1px;
bottom: -1px;
width: calc(100% + 2px);
background: $color;
border-bottom-left-radius: $r-control;
border-bottom-right-radius: $r-control;
}
.ml__stats {
display: flex;
gap: $sp-4;
margin: $sp-3 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(23, 20, 26, .64);
}
.ml__stat strong {
margin-right: $sp-1;
font-weight: 700;
color: $subject-ink;
font-variant-numeric: tabular-nums;
}
.ml__follow {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 36px;
margin: $sp-3 0 0;
padding: 0 $sp-4;
background: none;
border: 1px solid rgba(23, 20, 26, .14);
border-radius: $r-control;
color: $subject-ink;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
transition: border-color $dur-quick $easing;
}
.ml__follow:hover { border-color: rgba(23, 20, 26, .3); }
.ml__follow:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ml__i { display: inline-flex; }
.ml__i--check { display: none; color: $color; }
.ml__follow[aria-pressed="true"] .ml__i--plus { display: none; }
.ml__follow[aria-pressed="true"] .ml__i--check { display: inline-flex; }
// 실제 상호작용: 호버·키보드 포커스·팔로잉 상태에서 같은 밑줄이 그어진다
.ml__name:hover .ml__line--name,
.ml__follow:hover .ml__line--btn,
.ml__follow:focus-visible .ml__line--btn,
.ml__follow[aria-pressed="true"] .ml__line--btn { transform: scaleX(1); }
// ── 자동 루프: 이름 밑줄 → 버튼 아래 테두리(167ms 시차). 복귀는 88% 안 ──
// poster 는 "직전 프레임 대비 가장 크게 변한 프레임" 이라 등속·ease-out 으로 그으면
// 시작 직후의 짧은 작대기가 대표 이미지로 뽑힌다 (실측 2026-09-12). 그래서 마지막 한 칸을
// 한 샘플 간격(4.1667% = 83.3ms) 안에 몰아 넣어, 선이 끝까지 그어지는 프레임이 최대 변화가 되게 한다.
// 버튼 선의 시차 167ms 는 샘플 간격의 정확히 2배 — 컷이 프레임 사이에 딱 떨어진다.
// 되돌아오기는 linear — 한 프레임에 몰리면 그 프레임이 poster 를 뺏는다.
.ml.is-demo .ml__line--name {
animation-name: ml-draw-name;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ml.is-demo .ml__line--btn {
animation-name: ml-draw-btn;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-delay: 167ms;
animation-fill-mode: backwards;
}
@keyframes ml-draw-name {
0% { transform: scaleX(0); }
25% { transform: scaleX(.7); }
29% { transform: scaleX(1); }
54% { transform: scaleX(1); }
80% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@keyframes ml-draw-btn {
0% { transform: scaleX(0); }
25% { transform: scaleX(.4); }
29% { transform: scaleX(1); }
54% { transform: scaleX(1); }
80% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@media (max-width: 340px) {
.ml__card { width: 272px; padding: $sp-3; }
.ml__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 16px; }
.ml__name { margin-top: $sp-1; font-size: 13px; line-height: 16px; }
.ml__line--name { width: 44px; margin-top: 0; }
.ml__role { margin-top: 0; font-size: 11px; line-height: 14px; }
.ml__stats { margin-top: $sp-1; gap: $sp-3; font-size: 11px; line-height: 15px; }
.ml__follow { height: 32px; margin-top: $sp-1; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.ml.is-demo .ml__line--name,
.ml.is-demo .ml__line--btn { animation: none !important; }
.ml__line { transition: 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);
}
.ml {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
.ml__card {
display: flex;
flex-direction: column;
align-items: center;
width: 300px;
padding: 16px;
background: #fff;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
}
.ml__avatar {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border: 1px solid rgba(23, 20, 26, 0.22);
border-radius: 50%;
background: rgba(47, 109, 246, 0.08);
color: #17141a;
font-size: 17px;
line-height: 20px;
font-weight: 700;
}
.ml__name {
margin: 8px 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: #17141a;
text-align: center;
}
.ml__role {
margin: 4px 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(23, 20, 26, 0.64);
}
.ml__line {
display: block;
height: 2px;
background: #17141a;
transform: scaleX(0);
transform-origin: left center;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ml__line--name {
width: 56px;
margin: 4px auto 0;
}
.ml__line--btn {
position: absolute;
left: -1px;
bottom: -1px;
width: calc(100% + 2px);
background: #2f6df6;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.ml__stats {
display: flex;
gap: 16px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(23, 20, 26, 0.64);
}
.ml__stat strong {
margin-right: 4px;
font-weight: 700;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.ml__follow {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
margin: 12px 0 0;
padding: 0 16px;
background: none;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 12px;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ml__follow:hover {
border-color: rgba(23, 20, 26, 0.3);
}
.ml__follow:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ml__i {
display: inline-flex;
}
.ml__i--check {
display: none;
color: #2f6df6;
}
.ml__follow[aria-pressed=true] .ml__i--plus {
display: none;
}
.ml__follow[aria-pressed=true] .ml__i--check {
display: inline-flex;
}
.ml__name:hover .ml__line--name,
.ml__follow:hover .ml__line--btn,
.ml__follow:focus-visible .ml__line--btn,
.ml__follow[aria-pressed=true] .ml__line--btn {
transform: scaleX(1);
}
.ml.is-demo .ml__line--name {
animation-name: ml-draw-name;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ml.is-demo .ml__line--btn {
animation-name: ml-draw-btn;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-delay: 167ms;
animation-fill-mode: backwards;
}
@keyframes ml-draw-name {
0% {
transform: scaleX(0);
}
25% {
transform: scaleX(0.7);
}
29% {
transform: scaleX(1);
}
54% {
transform: scaleX(1);
}
80% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@keyframes ml-draw-btn {
0% {
transform: scaleX(0);
}
25% {
transform: scaleX(0.4);
}
29% {
transform: scaleX(1);
}
54% {
transform: scaleX(1);
}
80% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@media (max-width: 340px) {
.ml__card {
width: 272px;
padding: 12px;
}
.ml__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 16px;
}
.ml__name {
margin-top: 4px;
font-size: 13px;
line-height: 16px;
}
.ml__line--name {
width: 44px;
margin-top: 0;
}
.ml__role {
margin-top: 0;
font-size: 11px;
line-height: 14px;
}
.ml__stats {
margin-top: 4px;
gap: 12px;
font-size: 11px;
line-height: 15px;
}
.ml__follow {
height: 32px;
margin-top: 4px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.ml.is-demo .ml__line--name,
.ml.is-demo .ml__line--btn {
animation: none !important;
}
.ml__line {
transition: none;
}
}// 실제 동작: 팔로우 토글(aria-pressed) · 첫 조작에 자동 루프 종료
var root = document.querySelector('.ml');
var btn = document.getElementById('ml-follow');
var label = document.getElementById('ml-label');
function stopDemo() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopDemo);
root.addEventListener('keydown', stopDemo);
btn.addEventListener('click', function () {
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', on ? 'false' : 'true');
label.textContent = on ? '팔로우' : '팔로잉';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./MinimalLineCard.module.scss";
export type MinimalLineCardProps = {
name?: string;
role?: string;
initials?: string;
followers?: string;
posts?: string;
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconUserPlus = (): 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 MinimalLineCard({
name = "최연우",
role = "브랜드 전략가",
initials = "YW",
followers = "812",
posts = "41",
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MinimalLineCardProps): ReactNode {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const toggle = (e: ClickEvt): void => {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
};
return (
<div className={styles.ml} style={vars}>
<article className={styles.card}>
<div className={styles.avatar} aria-hidden="true">{initials}</div>
<h2 className={styles.name}>
{name}
<span className={styles.nameLine} aria-hidden="true" />
</h2>
<p className={styles.role}>{role}</p>
<p className={styles.stats}>
<span className={styles.stat}><strong>{followers}</strong>팔로워</span>
<span className={styles.stat}><strong>{posts}</strong>게시물</span>
</p>
<button type="button" className={styles.follow} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconUserPlus />}
<span className={styles.label}>{on ? "팔로잉" : "팔로우"}</span>
<span className={styles.btnLine} aria-hidden="true" />
</button>
</article>
</div>
);
}04 미니멀 라인 — 측정 누적면적 0.215% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 프로필 카드 디자인 9종 — 복붙하면 되는 스타일 모음
이 데모가 속한 글
프로필 카드 디자인 9종 — 복붙하면 되는 스타일 모음프로필 카드 디자인은 아바타·이름·직함·소셜 스탯·팔로우 버튼이라는 한 덩어리를 어떤 재질로 보여줄지 정하는 일입니다. 아홉 개는 그 안쪽 구조가 전부 같고, 바깥 재질과 움직임만 다릅니다. 이 글의 데모 전부 보기


