💭

[React.js/TypeScript] 管理画面系のサイドバー実装例

2025/01/16に公開

概要

今までたくさんの企業で管理画面やSaaSのUIを作ってきました。そのたびにサイドバーを実装してきましたが、さすがにそろそろテンプレート化しておこうと思った…というのがこの記事の執筆動機です。

この記事では、サイドバー実装のサンプルコードと、軽微な解説を掲載します。使用技術は React.js / TypeScript / CSS in JS になります。
Vue.js でも TailwindCSS でも、この記事で紹介した考え方自体は流用できると思います。

UI ライブラリは使用していません。管理画面やSaaS開発では、自前で作った方が拡張性があり、複雑なニーズに対応できるためです。

完成イメージ

ブログサービスを例にして、以下のような構造を持つサイドバーを作成していきます。

- 記事
    - 記事一覧
    - コメント一覧(通知の表示あり)
- 分析
    - アクセス解析
    - 購読者解析(説明文の表示あり)
- 各種設定
    - アカウント設定
    - メール配信設定
- サポート
    - お問い合わせ

ネスト構造は2段。1段目をグループ、2段目をリンクと呼称します。
1段目をクリックすることで、2段目が展開されます。
グループにはアイコンを付けます。リンクには通知数や説明文の表示が含まれることがあります。

型定義

まずは型定義。名称はお好みで変えてもOK。 path ではなく url にするとか。

interface Group {
    name: string;
    icon: string;
    links: Array<{
        name: string;
        path: string;
        isExternal?: boolean; // 外部リンクかどうか
        notificationKey?: string; // 未読数を引くための識別子
        description?: string; // 説明文を表示する場合
    }>
}

定数

次にサイドバーの内容を定数で定義します。分かりやすいようにアイコンには絵文字を指定していますが、実際のコードでは何かしらのSVGなどをインポートして設定することになるかと思います。

const groups: Array<Group> = [
    {
        name: '記事',
        icon: '✒️',
        links: [
            {
                name: '記事一覧',
                path: '/articles',
            },
            {
                name: 'コメント一覧',
                path: '/comments',
                notificationKey: 'comments',
            }
        ]
    },
    {
        name: '分析',
        icon: '📈',
        links: [
            {
                name: 'アクセス解析',
                path: '/analytics',
            },
            {
                name: '購読者解析',
                path: '/subscribers',
                description: 'これはΒ版の機能になります'
            }
        ]
    },
    {
        name: '各種設定',
        icon: '⚙️',
        links: [
            {
                name: 'アカウント設定',
                path: '/account',
            },
            {
                name: 'メール配信設定',
                path: '/mail',
            }
        ]
    },
    {
        name: 'サポート',
        icon: '🛠️',
        links: [
            {
                name: 'お問い合わせ',
                path: 'https://suppport.xxxxxxxxx.com/contact',
                isExternal: true,
            }
        ]
    }
]

ここで大事なのは、この定数を static に保つことです。
この定数を上書きして、未読数などを埋め込みたくなるかもしれませんが、そうするとコードが複雑化します。

状態管理

次に状態管理です。今回は2つの状態を定義しています。

import { useState } from 'react';

// ここに型定義と定数定義

const Sidebar: React.FC = () = {
    /**
     * 展開しているグループのindexを保持
     *
     * <label > と <input type="checkbox" hidden /> と CSS の has(:checked) を使えば JavaScript 不要で実装できるが、CSS側が複雑になるのでやめておく
     */
    const [openState, setOpenState] = useState<Set<number>>(new Set());
    
    /**
     * 通知数を保持
     *
     * APIフェッチしたデータなどを、useEffectなどでsetStateすればOK
     * ReactQueryなどを使っている場合は、そのデータをそのまま使えばOK
     */
    const [notifications, setNotifications] = useState<{ [key: string]: number }>({});
    
    /**
     * グループを展開/折りたたむ
     *
     * ビュー側のコードをシンプルにするため、Setのやり取りはここで定義しておく
     */
    const toggleGroup = (index: number) => {
        setOpenState(prev => {
            const newState = new Set(prev);
            newState.has(index) ? newState.delete(index) : newState.add(index);
            return newState;
        });
    }
}

HTML・CSS

いよいよHTML・CSSです。まずはHTMLから。

import { useState, Fragment } from 'react';

// ここに型定義と定数定義

const Sidebar: React.FC = () => {

    // ここに先ほど定義した状態管理系のコード

    return (
        <Wrapper>
          {groups.map((group, index) => (
            <Fragment key={group.name}>
              <Group onClick={() => toggleGroup(index)}>
                <Icon src={group.icon} alt={`${group.name}のアイコン`} />
                {group.name}
                <ToggleIcon open={openState.has(index)} />
              </Group>
    
              <Links open={openState.has(index)}>
                {group.links.map((link, i) => (
                  <Fragment key={link.path}>
                    {link.isExternal ? (
                      // 外部リンク
                      <ExternalLink
                        href={link.path}
                        target="_blank"
                        rel="noopener"
                        // グループが閉じているときにタブのフォーカスを当てない
                        tabIndex={openState.has(index) ? 0 : -1}
                      >
                        {link.name}
                      </ExternalLink>
                    ) : (
                      // SPA遷移
                      <Link
                        to={link.path}
                        // グループが閉じているときにタブのフォーカスを当てない
                        tabIndex={openState.has(index) ? 0 : -1}
                      >
                        {link.notificationKey && notifications[link.notificationKey] > 0 && (
                          <Notification>
                            {notifications[link.notificationKey]}
                          </Notification>
                        )}
                        {link.name}
                      </Link>
                    )}
    
                    {link.description && <Description>{link.description}</Description>}
                  </Fragment>
                ))}
              </Links>
            </Fragment>
          ))}
        </Wrapper>
    )
}

次にCSS。自分はemotionを使っていますが、他のCSSinJSライブラリでもそんなに変わりはないかなと思います。

import { Link as OriginalLink } from 'react-router-dom';

// サイドバー全体を包むラッパー。余白や背景色はお好みで。
const Wrapper = styled.div`
  height: 100%;
  padding: 4px 0 30px;
  background-color: var(--bg-color);
  overflow-y: auto;
`;

// グループ。展開・縮小を行うのでボタンとして定義。hoverするとふわっと色がつくようなスタイルになってます。
const Group = styled.button`
  position: relative;
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  margin-top: 4px;
  padding: 12px 14px;
  border-radius: 0 30px 30px 0;
  border: none;
  background-color: transparent;
  text-align: left;
  font-size: var(--font-large);
  font-weight: var(--font-bold);
  cursor: pointer;
  transition: background-color 0.2s ease-out;

  &:hover {
    background-color: var(--accent-color);
  }
`;

const Icon = styled.img`
  width: 20px;
  height: 20px;
`;

// よくある「くの字」。展開・縮小を分かりやすくするため。
const ToggleIcon = styled.div<{ open: boolean }>`
  position: relative;
  margin-left: auto; // 右端に寄せる
  &::before {
    content: '';
    width: 5px;
    height: 5px;
    border-top: solid 2px var(--gray);
    border-right: solid 2px var(--gray);
    position: absolute;
    top: 0;
    left: 0;
    transform: ${(props) => (props.open ? 'rotate(135deg)' : 'rotate(45deg)')};
  }
`;

const Links = styled.div<{ open: boolean }>`
  display: flex;
  flex-direction: column;
  gap: 4px;
  height: 0; // デフォルトで0pxの高さ
  padding: ${(props) => (props.open ? '8px 4px 10px' : '0')}; // 展開時のみ余白
  opacity: ${(props) => (props.open ? 1 : 0)}; // ふわっと表示
  overflow: hidden;
  transition: all 0.2s ease-out;

  ${(props) =>
    props.open
      ? `
        height: auto; /* fallback */
        height: calc-size(auto, size); /* height: auto を transition させる */
      `
      : ''};
`;

// リンクテキストの共通スタイル
const linkStyle = css`
  font-size: var(--font-medium);
  font-weight: var(--font-semi-bold);
  transition: opacity 0.1s ease-out;
  &:hover {
    opacity: 0.7; // ふわっと
  }
`;

// SPAリンク
const Link = styled(OriginalLink)`
  position: relative;
  ${linkStyle}
`;

// 外部リンク
const ExternalLink = styled.a`
  ${linkStyle}
`;

// 通知数のバッジ表示
const Notification = styled.span`
  position: absolute;
  top: 50%;
  right: 0;
  transform: translateY(-50%);
  width: 18px;
  height: 18px;
  background-color: var(--warn);
  color: var(--white);
  text-align: center;
  border-radius: 50%;
  font-weight: var(--font-bold);
  font-size: var(--font-small);
`;

// 説明文
const Description = styled.p`
  padding: 8px 12px;
  background-color: var(--secondary);
  border-radius: 10px;
  font-size: var(--font-default);
  line-height: 1.5;
`;

完成

すべてのパーツが出揃い、サイドバーが完成しました。
アイコン画像など、記事中の解説とは異なりますが、だいたい以下のような挙動になるかなと思います。

Discussion