06팀 멤버 리스트 (검색)
이름을 입력하면 그 글자가 들어간 멤버만 실제로 남고 나머지는 실제로 숨겨지며, 온라인 상태 점은 계속 옅은 고리로 퍼졌다 사라집니다.
쓰임완성된 팀 관리 페이지 없이 멤버 수가 많은 목록에 검색창 하나만 붙이고 싶을 때
keyuphiddenscale

<main class="stage">
<div class="tl">
<div class="tl__head">
<input class="tl__search" type="text" placeholder="이름으로 검색" aria-label="멤버 검색">
<button class="tl__invite" type="button" aria-label="초대">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m48-88a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8"/></g></svg>
</button>
</div>
<ul class="tl__list">
<li class="tl__row" data-name="김도윤">
<span class="tl__avatar">김<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">김도윤</span>
</li>
<li class="tl__row" data-name="이서준">
<span class="tl__avatar">이<span class="tl__status tl__status--away"></span></span>
<span class="tl__name">이서준</span>
</li>
<li class="tl__row" data-name="박하은">
<span class="tl__avatar">박<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">박하은</span>
</li>
<li class="tl__row tl__row--new is-demo" data-name="최유진">
<span class="tl__avatar">최<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">최유진</span>
</li>
</ul>
<p class="tl__empty" hidden>일치하는 멤버가 없습니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-pop;
$color: $subject-sky;
@include stage($stage-yellow);
.tl {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.tl__head { display: flex; align-items: center; gap: $sp-2; margin: 0 0 $sp-3; }
.tl__search {
flex: 1 1 auto; min-width: 0;
box-sizing: border-box;
border: 1px solid rgba(23, 20, 26, .18);
background: #fff;
border-radius: $r-control;
padding: $sp-1 $sp-2;
font: inherit; font-size: 11px; color: $stage-ink;
}
.tl__search::placeholder { color: rgba(23, 20, 26, .4); }
.tl__invite {
flex: 0 0 auto;
width: 26px; height: 26px; padding: 0;
border: 0; background: $stage-ink; color: $stage-yellow;
border-radius: 50%;
display: grid; place-items: center; cursor: pointer;
}
.tl__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-2; }
.tl__row { display: flex; align-items: center; gap: $sp-2; }
.tl__avatar {
position: relative;
flex: 0 0 auto;
width: 24px; height: 24px;
border-radius: 50%;
background: #fff;
display: grid; place-items: center;
font-size: 10px; font-weight: 800;
}
.tl__status {
position: absolute; right: -1px; bottom: -1px;
width: 8px; height: 8px;
border-radius: 50%;
border: 2px solid $stage-yellow;
}
.tl__status--on { background: $subject-mint; }
.tl__status--away { background: #d0bd9f; }
.tl__status--on::after {
content: "";
position: absolute; inset: -2px;
border-radius: 50%;
border: 1px solid $subject-mint;
opacity: .8;
animation: tl-ping 2.4s $easing infinite;
}
.tl__name { font-size: 12px; font-weight: 600; }
.tl__empty { font-size: 11px; color: rgba(23, 20, 26, .5); margin: $sp-2 0 0; }
@keyframes tl-ping {
0% { transform: scale(1); opacity: .8; }
100% { transform: scale(1.9); opacity: 0; }
}
.tl__row--new {
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing;
}
// ── 자동 루프: 새 멤버 행이 계속 나타났다 사라진다 ──────────────
.tl__row--new.is-demo { animation: tl-join 2.6s $easing infinite; }
@keyframes tl-join {
0% { opacity: 0; transform: translateY(8px); }
30% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(8px); }
}
@media (max-width: 340px) {
.tl__name { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.tl__status--on::after { animation: none !important; opacity: 0; }
.tl__row--new.is-demo { animation: none !important; opacity: 1; transform: translateY(0); }
.tl__row--new { 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);
}
.tl {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.tl__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
}
.tl__search {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
border: 1px solid rgba(23, 20, 26, 0.18);
background: #fff;
border-radius: 12px;
padding: 4px 8px;
font: inherit;
font-size: 11px;
color: #17141a;
}
.tl__search::placeholder {
color: rgba(23, 20, 26, 0.4);
}
.tl__invite {
flex: 0 0 auto;
width: 26px;
height: 26px;
padding: 0;
border: 0;
background: #17141a;
color: #ffd23f;
border-radius: 50%;
display: grid;
place-items: center;
cursor: pointer;
}
.tl__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.tl__row {
display: flex;
align-items: center;
gap: 8px;
}
.tl__avatar {
position: relative;
flex: 0 0 auto;
width: 24px;
height: 24px;
border-radius: 50%;
background: #fff;
display: grid;
place-items: center;
font-size: 10px;
font-weight: 800;
}
.tl__status {
position: absolute;
right: -1px;
bottom: -1px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid #ffd23f;
}
.tl__status--on {
background: #4cd4a6;
}
.tl__status--away {
background: #d0bd9f;
}
.tl__status--on::after {
content: "";
position: absolute;
inset: -2px;
border-radius: 50%;
border: 1px solid #4cd4a6;
opacity: 0.8;
animation: tl-ping 2.4s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.tl__name {
font-size: 12px;
font-weight: 600;
}
.tl__empty {
font-size: 11px;
color: rgba(23, 20, 26, 0.5);
margin: 8px 0 0;
}
@keyframes tl-ping {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(1.9);
opacity: 0;
}
}
.tl__row--new {
opacity: 0;
transform: translateY(8px);
transition: opacity 600ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 600ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tl__row--new.is-demo {
animation: tl-join 2.6s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes tl-join {
0% {
opacity: 0;
transform: translateY(8px);
}
30% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (max-width: 340px) {
.tl__name {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.tl__status--on::after {
animation: none !important;
opacity: 0;
}
.tl__row--new.is-demo {
animation: none !important;
opacity: 1;
transform: translateY(0);
}
.tl__row--new {
transition: none;
}
}// 실제 동작: 이름을 입력하면 일치하는 멤버만 실제로 남고 나머지는 실제로 숨겨진다.
var input = document.querySelector('.tl__search');
var rows = document.querySelectorAll('.tl__row');
var empty = document.querySelector('.tl__empty');
input.addEventListener('input', function () {
var q = input.value.trim().toLowerCase();
var shown = 0;
rows.forEach(function (r) {
var hit = r.dataset.name.toLowerCase().indexOf(q) !== -1;
r.hidden = !hit;
if (hit) shown++;
});
empty.hidden = shown !== 0;
});import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TeamList.module.scss";
export type TeamListProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const MEMBERS = [
{ name: "Kim Doyoon", initial: "K", status: "on" as const },
{ name: "Lee Seojun", initial: "L", status: "away" as const },
{ name: "Park Haeun", initial: "P", status: "on" as const },
];
export default function TeamList({ duration = 600, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#6ec8ff" }: TeamListProps) {
const [query, setQuery] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const filtered = useMemo(
() => MEMBERS.filter((m) => m.name.toLowerCase().includes(query.trim().toLowerCase())),
[query]
);
return (
<div className={styles.tl} style={vars}>
<div className={styles.head}>
<input
className={styles.search}
type="text"
placeholder="Search by name"
aria-label="Member search"
value={query}
onChange={(e: any) => setQuery(e.target.value)}
/>
<button type="button" className={styles.invite} aria-label="Invite">+</button>
</div>
<ul className={styles.list}>
{filtered.map((m) => (
<li key={m.name} className={styles.row}>
<span className={styles.avatar}>
{m.initial}
<span className={`${styles.status} ${styles[m.status]}`} />
</span>
<span className={styles.name}>{m.name}</span>
</li>
))}
</ul>
{filtered.length === 0 && <p className={styles.empty}>No matching members</p>}
</div>
);
}06 팀 멤버 리스트 (검색) — 측정 누적면적 1.09% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 대시보드 컴포넌트 9종 — 화면 통째로 말고 이 부품만
이 데모가 속한 글
대시보드 컴포넌트 9종 — 화면 통째로 말고 이 부품만대시보드 컴포넌트는 완성된 관리자 화면을 통째로 붙이지 않고 헤더·피드·캘린더처럼 화면 조각 하나만 그대로 옮겨 쓰는 UI 부품입니다. 아홉 개 중 하나는 Ctrl(⌘)+K로 여는 커맨드 팔레트인데, 방향키로 실제로 이 글의 데모 전부 보기


