컴포넌트
계정 영역
로그아웃 상태에서는 로그인 링크, 로그인 상태에서는 이름 칩과 로그아웃이 바로 보여요.
- #arc
- #component
- #account
- #auth
- #login
- #header
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/account-area.json파일
components/arc/account-area/account-area.module.csscomponents/arc/account-area/account-area.tsx
npm 패키지
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10
함께 오는 항목
account-area.module.cssaccount-area.module.cssaccount-area.module.csscss
.area { display: inline-flex; min-width: 0; align-items: center; gap: var(--space-1); }
.form { display: contents; }
.login, .chip, .logout { display: inline-flex; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--label-md); font-weight: var(--label-weight); line-height: var(--leading-body); text-decoration: none; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); }
.login { padding: 0 var(--space-3); }
/* The chip keeps the avatar flush with its own edge, so the circle reads as part of the pill rather than an icon inside it. */
.chip { min-width: 0; padding: 0 var(--space-3) 0 var(--space-1); background: var(--surface-muted); color: var(--foreground); }
.chip[aria-current="page"] { background: var(--accent-subtle); color: var(--accent); }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The highlight fill carries its own text color in both themes, so the initials hold about 9:1 either way. */
.avatar { display: inline-flex; width: calc(var(--space-6) + var(--space-1)); height: calc(var(--space-6) + var(--space-1)); flex: none; align-items: center; justify-content: center; overflow: hidden; border-radius: var(--radius-pill); background: var(--highlight); color: var(--highlight-foreground); font-size: var(--text-xs); font-weight: var(--label-weight); }
.logout { padding: 0 var(--space-3); }
.logout svg { flex: none; }
@media (hover: hover) and (pointer: fine) {
.login:hover, .logout:hover { background: var(--surface-muted); color: var(--foreground); }
.chip:hover { background: var(--surface-sunken); }
.chip[aria-current="page"]:hover { background: var(--accent-subtle); }
}
.login:active, .logout:active { background: var(--surface-sunken); color: var(--foreground); }
.chip:active { background: var(--surface-sunken); }
/* Below sm the word would push the CTA off the bar, so the icon carries it and title keeps the name. */
@media (max-width: 479px) { .logoutLabel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } }
@media (prefers-reduced-motion: reduce) { .login, .chip, .logout { transition: none; } }
account-area.tsxaccount-area.tsxaccount-area.tsxtsx
"use client";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Logout01Icon } from "@hugeicons/core-free-icons";
import { playUiSound } from "../sound/play";
import styles from "./account-area.module.css";
export interface AccountViewer {
name: string;
/** Where the name chip goes. Left out, the chip is plain text. */
href?: string;
/** Replaces the initials, e.g. an Avatar. */
avatar?: ReactNode;
}
export interface AccountLabels {
login: string;
logout: string;
/** The title on the name chip, e.g. "내 계정". */
account: string;
}
export interface AccountAreaProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
/** The person who is signed in. null or undefined shows the sign-in link instead. */
viewer?: AccountViewer | null;
loginHref: string;
labels: AccountLabels;
/** Signs out by posting to this path. Left out, signing out calls onLogout instead. */
logoutAction?: string;
onLogout?: () => void;
/** Runs when either link is followed, so a menu holding this can close itself. */
onNavigate?: () => void;
/** Marks the name chip as the current page. */
current?: boolean;
linkProps?: AnchorHTMLAttributes<HTMLAnchorElement>;
}
/** Two letters at most, so the circle stays readable at 28px. */
export function initialsOf(name: string): string {
return name.trim().split(/\s+/).slice(0, 2).map(part => part[0]?.toUpperCase() ?? "").join("") || "?";
}
/**
* The account corner of a header or menu. Signed out it is one link; signed in the name and the way out are both
* visible, instead of hiding sign-out inside a menu the way a user menu does — in a header there is room for it,
* and one less press to leave is worth the width.
*/
export function AccountArea({
className,
viewer,
loginHref,
labels,
logoutAction,
onLogout,
onNavigate,
current = false,
linkProps,
...props
}: AccountAreaProps) {
if (!viewer) {
return (
<div {...props} className={[styles.area, className].filter(Boolean).join(" ")}>
<a
{...linkProps}
href={loginHref}
className={styles.login}
onClick={() => { playUiSound("tick"); onNavigate?.(); }}
>
{labels.login}
</a>
</div>
);
}
const chip = (
<>
<span className={styles.avatar} aria-hidden="true">{viewer.avatar ?? initialsOf(viewer.name)}</span>
<span className={styles.name}>{viewer.name}</span>
</>
);
return (
<div {...props} className={[styles.area, className].filter(Boolean).join(" ")}>
{viewer.href ? (
<a
{...linkProps}
href={viewer.href}
title={labels.account}
aria-current={current ? "page" : undefined}
className={styles.chip}
onClick={() => { playUiSound("tick"); onNavigate?.(); }}
>
{chip}
</a>
) : (
<span className={styles.chip}>{chip}</span>
)}
{logoutAction ? (
<form method="post" action={logoutAction} className={styles.form}>
<button type="submit" className={styles.logout} title={labels.logout} onClick={() => playUiSound("click")}>
<HugeiconsIcon icon={Logout01Icon} size={16} strokeWidth={1.75} aria-hidden="true" />
<span className={styles.logoutLabel}>{labels.logout}</span>
</button>
</form>
) : (
<button type="button" className={styles.logout} title={labels.logout} onClick={() => { playUiSound("click"); onLogout?.(); }}>
<HugeiconsIcon icon={Logout01Icon} size={16} strokeWidth={1.75} aria-hidden="true" />
<span className={styles.logoutLabel}>{labels.logout}</span>
</button>
)}
</div>
);
}
AccountArea.displayName = "AccountArea";
export default AccountArea;