03Social login — a button group that recomputes column count from how many providers there are
Counting the provider buttons works out how many columns fit one row, and spacing and size get recalculated so the layout stays even whether providers are added or removed.
Best forSocial sign-in button rows, quick-signup option areas
grid-template-columnsclip-pathclassList

<main class="stage">
<section class="wrap" id="wrap">
<p class="hint" id="hint">Out of 5 login methods, <span id="hintCount">2</span> shown</p>
<div class="tick" id="tick" aria-hidden="true"><div class="tickBar" id="tickBar"></div></div>
<div class="row" id="row" style="--cols:2">
<button type="button" class="btn" id="btn0">
<span class="icon iconCircle" aria-hidden="true"></span>
<span class="label">Provider A</span>
</button>
<button type="button" class="btn" id="btn1">
<span class="icon iconSquare" aria-hidden="true"></span>
<span class="label">Provider B</span>
</button>
<button type="button" class="btn is-hidden" id="btn2">
<span class="icon iconTriangle" aria-hidden="true"></span>
<span class="label">Provider C</span>
</button>
<button type="button" class="btn is-hidden" id="btn3">
<span class="icon iconDiamond" aria-hidden="true"></span>
<span class="label">Provider D</span>
</button>
<button type="button" class="btn is-hidden" id="btn4">
<span class="icon iconStar" aria-hidden="true"></span>
<span class="label">Provider E</span>
</button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.wrap {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
text-align: center;
}
.hint {
margin: 0;
color: rgba($subject-ink, .72);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
}
.hint span {
color: $subject-ink;
font-weight: 700;
}
.tick {
width: 100%;
max-width: 120px;
height: $sp-1;
border-radius: $r-pill;
background: rgba($subject-ink, .14);
overflow: hidden;
}
.tickBar {
--within: 0;
width: 100%;
height: 100%;
border-radius: $r-pill;
background: $subject-ink;
transform: scaleX(var(--within, 0));
transform-origin: left;
}
.row {
--cols: 2;
width: 100%;
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: $sp-2;
}
.btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-1;
padding: $sp-2 $sp-1;
border: 0;
border-radius: $r-control;
background: rgba($subject-ink, .08);
cursor: default;
}
.btn.is-hidden {
display: none;
}
.icon {
width: $sp-4;
height: $sp-4;
animation: iconPulse $duration $easing infinite;
}
.iconCircle {
border-radius: $r-pill;
background: $subject-ink;
}
.iconSquare {
border-radius: $r-xs;
background: $subject-ink;
}
.iconTriangle {
width: 0;
height: 0;
border-left: $sp-2 solid transparent;
border-right: $sp-2 solid transparent;
border-bottom: $sp-4 solid $subject-ink;
}
.iconDiamond {
background: $subject-ink;
border-radius: $r-xs;
transform: rotate(45deg);
}
.iconStar {
background: $subject-ink;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.label {
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 10px;
}
@keyframes iconPulse {
0%, 100% { opacity: .82; }
50% { opacity: 1; }
}
@media (max-width: 340px) {
.wrap { max-width: 280px; }
.label { font-size: 9px; }
.btn { padding: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.icon { animation: 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);
}
.wrap {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.hint {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
}
.hint span {
color: #17141a;
font-weight: 700;
}
.tick {
width: 100%;
max-width: 120px;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
overflow: hidden;
}
.tickBar {
--within: 0;
width: 100%;
height: 100%;
border-radius: 999px;
background: #17141a;
transform: scaleX(var(--within, 0));
transform-origin: left;
}
.row {
--cols: 2;
width: 100%;
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: 8px;
}
.btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 4px;
border: 0;
border-radius: 12px;
background: rgba(23, 20, 26, 0.08);
cursor: default;
}
.btn.is-hidden {
display: none;
}
.icon {
width: 16px;
height: 16px;
animation: iconPulse 2s ease-out infinite;
}
.iconCircle {
border-radius: 999px;
background: #17141a;
}
.iconSquare {
border-radius: 4px;
background: #17141a;
}
.iconTriangle {
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 16px solid #17141a;
}
.iconDiamond {
background: #17141a;
border-radius: 4px;
transform: rotate(45deg);
}
.iconStar {
background: #17141a;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.label {
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 10px;
}
@keyframes iconPulse {
0%, 100% {
opacity: 0.82;
}
50% {
opacity: 1;
}
}
@media (max-width: 340px) {
.wrap {
max-width: 280px;
}
.label {
font-size: 9px;
}
.btn {
padding: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.icon {
animation: none;
}
}// 소셜 Log in(03) — 제공자 개수가 시나리오에 따라 바뀔 때 그리드 열 수를 재계산한다.
// 브랜드 로고·브랜드명 없이 순수 CSS 도형 아이콘 + 가상명("Provider A" 류)만 사용.
(function () {
var row = document.getElementById('row');
var hintCount = document.getElementById('hintCount');
var tickBar = document.getElementById('tickBar');
var btnIds = ['btn0', 'btn1', 'btn2', 'btn3', 'btn4'];
var btns = btnIds.map(function (id) { return document.getElementById(id); });
if (!row || !hintCount || !tickBar || btns.some(function (b) { return !b; })) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var COUNTS = [2, 3, 4, 5, 3];
var STEP_MS = 400;
var CYCLE_MS = COUNTS.length * STEP_MS;
function colsFor(n) {
return n <= 3 ? n : Math.ceil(n / 2);
}
function apply(t) {
var tt = t % CYCLE_MS;
var idx = Math.min(Math.max(Math.floor(tt / STEP_MS), 0), COUNTS.length - 1);
var n = COUNTS[idx];
var cols = colsFor(n);
row.style.setProperty('--cols', String(cols));
hintCount.textContent = String(n);
btns.forEach(function (btn, i) {
if (i < n) {
btn.classList.remove('is-hidden');
} else {
btn.classList.add('is-hidden');
}
});
// 다음 개수 전환까지 남은 구간을 매 프레임 다시 계산해 가는 줄(계산 한 겹) —
// 그리드 재배치가 갑자기 튀지 않고 다음 변화가 다가옴을 예고한다.
var within = (tt % STEP_MS) / STEP_MS;
tickBar.style.setProperty('--within', within.toFixed(4));
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
apply(ts - start);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./SocialLogin.module.scss";
// 소셜 로그인 — 제공자 개수가 시나리오에 따라 바뀔 때 그리드 열 수를 재계산한다.
// 브랜드 로고·브랜드명 없이 순수 CSS 도형 아이콘 + 가상명("Provider A" 류)만 사용.
const COUNTS = [2, 3, 4, 5, 3];
const STEP_MS = 400;
const CYCLE_MS = COUNTS.length * STEP_MS;
const PROVIDERS = [
{ id: "btn0", icon: "iconCircle", label: "Provider A" },
{ id: "btn1", icon: "iconSquare", label: "Provider B" },
{ id: "btn2", icon: "iconTriangle", label: "Provider C" },
{ id: "btn3", icon: "iconDiamond", label: "Provider D" },
{ id: "btn4", icon: "iconStar", label: "Provider E" },
] as const;
function colsFor(n: number) {
return n <= 3 ? n : Math.ceil(n / 2);
}
export default function SocialLogin() {
const rowRef = useRef<HTMLDivElement>(null);
const hintCountRef = useRef<HTMLSpanElement>(null);
const tickBarRef = useRef<HTMLDivElement>(null);
const btnRefs = [
useRef<HTMLButtonElement>(null),
useRef<HTMLButtonElement>(null),
useRef<HTMLButtonElement>(null),
useRef<HTMLButtonElement>(null),
useRef<HTMLButtonElement>(null),
];
useEffect(() => {
const row = rowRef.current;
const hintCount = hintCountRef.current;
const tickBar = tickBarRef.current;
const btns = btnRefs.map((r) => r.current);
if (!row || !hintCount || !tickBar || btns.some((b) => !b)) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function apply(t: number) {
const tt = t % CYCLE_MS;
const idx = Math.min(Math.max(Math.floor(tt / STEP_MS), 0), COUNTS.length - 1);
const n = COUNTS[idx];
const cols = colsFor(n);
row!.style.setProperty("--cols", String(cols));
hintCount!.textContent = String(n);
btns.forEach((btn, i) => {
if (!btn) return;
if (i < n) {
btn.classList.remove(styles.isHidden);
} else {
btn.classList.add(styles.isHidden);
}
});
// 다음 개수 전환까지 남은 구간을 매 프레임 다시 계산해 가는 줄(계산 한 겹) —
// 그리드 재배치가 갑자기 튀지 않고 다음 변화가 다가옴을 예고한다.
const within = (tt % STEP_MS) / STEP_MS;
tickBar!.style.setProperty("--within", within.toFixed(4));
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
apply(ts - start);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={styles.stage}>
<section className={styles.wrap}>
<p className={styles.hint}>
로그인 방법 5개 중 <span ref={hintCountRef}>2</span>개 표시
</p>
<div className={styles.tick} aria-hidden="true">
<div className={styles.tickBar} ref={tickBarRef} />
</div>
<div className={styles.row} ref={rowRef}>
{PROVIDERS.map((p, i) => (
<button
type="button"
className={i >= 2 ? `${styles.btn} ${styles.isHidden}` : styles.btn}
ref={btnRefs[i]}
key={p.id}
>
<span className={`${styles.icon} ${styles[p.icon]}`} aria-hidden="true" />
<span className={styles.label}>{p.label}</span>
</button>
))}
</div>
</section>
</div>
);
}03 Social login — a button group that recomputes column count from how many providers there are — Measured area 23.165% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Login UI Design Patterns (Copy-Paste)
The post this demo lives in
9 Login UI Design Patterns (Copy-Paste)Nine login and signup patterns that compute: fill ratio refills a progress ring, remaining time recounts into an arc eve See all demos in this post

