Skip to content
Component

Account area

A sign-in link when signed out, and the name with a visible way out when signed in.

  • #arc
  • #component
  • #account
  • #auth
  • #login
  • #header

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/account-area.json

account-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.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;