04Minimal line
On a card made of nothing but hairlines and whitespace, a rule draws itself from the left under the name and the follow button's edge fills the same way.
Best forContact cards on bright finance, legal, and B2B SaaS pages where trust comes before decoration
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">Yeonwoo Choi<span class="ml__line ml__line--name" aria-hidden="true"></span></h2>
<p class="ml__role">Brand strategist</p>
<p class="ml__stats">
<span class="ml__stat"><strong>812</strong>Followers</span>
<span class="ml__stat"><strong>41</strong>Posts</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">Follow</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;
}
}// 실제 동작: Follow 토글(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 ? 'Follow' : 'Following';
});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 Minimal line — Measured area 0.215% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · 9 Profile Card Design Styles, Copy-Paste Ready
The post this demo lives in
9 Profile Card Design Styles, Copy-Paste ReadyProfile card design is the job of deciding what material a fixed block of content wears: avatar, name, role, stats, foll See all demos in this post


