🎈

Next.js × Tremorを使ったStripe風ダッシュボードの構築

2023/11/24に公開

シンプルかつ簡単に実装可能なチャート系UIライブラリTremorについて学習したのでアウトプットとして記事にまとめました。

今回は分析ツールのダッシュボードということで、有名なSaaSツールStripeとGoogle AnalyticsのUIを真似て作ったので、少しでもダッシュボードを実装する方の参考になれば幸いです。

https://www.tremor.so/

🏠 完成図

実装工数もUI構築だけなら1~2日程度で実装できます!そこもTermorの強みです!

※全てサンプルデータです。

📝 要件定義

簡易的に要件をまとめるとこんな感じです。

  • 週別の売上の数値化・視覚化
  • 売上の詳細の数値化・視覚化
  • アプリ流入経路の数値化・視覚化
  • 獲得ユーザーのカテゴリーごとの数値化・視覚化
  • KPI達成率の数値化・視覚化
  • ユーザーアクティブ時刻の数値化・視覚化

👨‍💻 技術スタック

Nextjs

https://nextjs.org/

Vue.jsと同じぐらい人気のあるReactフレームワーク。
最近appディレクトリーがstableになり、今回はappディレクトリを使用して実装しています。

shadcn/ui

https://ui.shadcn.com/

Radix UI と Tailwind CSS を使用して構築された再利用可能なコンポーネント。
必要なコンポーネントのみをインストールして利用可能であり、また仕様に合わせてをUI簡単に拡張することが可能。

使用感としてNextjsとの相性も良さそう。

Tremor

https://www.tremor.so/

チャート系の他にbutton等のコンポーネントも提供しており、簡単にcssでUIを実装することができます。

Lucide

https://lucide.dev/

かなり便利なアイコンライブラリです。大きさ、太さ、カラーなど簡単に変更できます。
また、svg提供していて最強です。

🛠️ 環境構築

Next.jsをインストールする。

bun create next-app 

今回は最近リリースされたbunを使用します。
(めっちゃ早いですw)

https://bun.sh/

shadcn/uiのインストールする。

bunx --bun shadcn-ui@latest init

shadcn/uiの初期設定をコマンドで実行する。

Would you like to use TypeScript (recommended)? yes
Which style would you like to use?Default
Which color would you like to use as base color?Slate
Where is your global CSS file? › › @/styles/globals.css
Do you want to use CSS variables for colors? › yes
Where is your tailwind.config.js located? › tailwind.config.js
Configure the import alias for components: › @/components
Configure the import alias for utils: › @/lib/utils
Are you using React Server Components? › yes

参考ドキュメント:
https://ui.shadcn.com/docs/installation/next

インストールが完了したら、tailwin.config.jsを以下のように追加します。

tailwin.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: ["class"],
  content: ["./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}", "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}"],
  theme: {
    container: {
      center: true,
      padding: "2rem",
      screens: {
        "2xl": "1400px",
      },
    },
    extend: {
      colors: {
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: {
          DEFAULT: "hsl(var(--primary))",
          foreground: "hsl(var(--primary-foreground))",
        },
        secondary: {
          DEFAULT: "hsl(var(--secondary))",
          foreground: "hsl(var(--secondary-foreground))",
        },
        destructive: {
          DEFAULT: "hsl(var(--destructive))",
          foreground: "hsl(var(--destructive-foreground))",
        },
        muted: {
          DEFAULT: "hsl(var(--muted))",
          foreground: "hsl(var(--muted-foreground))",
        },
        accent: {
          DEFAULT: "hsl(var(--accent))",
          foreground: "hsl(var(--accent-foreground))",
        },
        popover: {
          DEFAULT: "hsl(var(--popover))",
          foreground: "hsl(var(--popover-foreground))",
        },
        card: {
          DEFAULT: "hsl(var(--card))",
          foreground: "hsl(var(--card-foreground))",
        },
        // light mode
        tremor: {
          brand: {
            faint: "#eff6ff", // blue-50
            muted: "#bfdbfe", // blue-200
            subtle: "#60a5fa", // blue-400
            DEFAULT: "#3b82f6", // blue-500
            emphasis: "#1d4ed8", // blue-700
            inverted: "#ffffff", // white
          },
          background: {
            muted: "#f9fafb", // gray-50
            subtle: "#f3f4f6", // gray-100
            DEFAULT: "#ffffff", // white
            emphasis: "#374151", // gray-700
          },
          border: {
            DEFAULT: "#e5e7eb", // gray-200
          },
          ring: {
            DEFAULT: "#e5e7eb", // gray-200
          },
          content: {
            subtle: "#9ca3af", // gray-400
            DEFAULT: "#6b7280", // gray-500
            emphasis: "#374151", // gray-700
            strong: "#111827", // gray-900
            inverted: "#ffffff", // white
          },
        },
        "dark-tremor": {
          brand: {
            faint: "#0B1229", // custom
            muted: "#172554", // blue-950
            subtle: "#1e40af", // blue-800
            DEFAULT: "#3b82f6", // blue-500
            emphasis: "#60a5fa", // blue-400
            inverted: "#030712", // gray-950
          },
          background: {
            muted: "#131A2B", // custom
            subtle: "#1f2937", // gray-800
            DEFAULT: "#111827", // gray-900
            emphasis: "#d1d5db", // gray-300
          },
          border: {
            DEFAULT: "#374151", // gray-700
          },
          ring: {
            DEFAULT: "#1f2937", // gray-800
          },
          content: {
            subtle: "#4b5563", // gray-600
            DEFAULT: "#6b7280", // gray-500
            emphasis: "#e5e7eb", // gray-200
            strong: "#f9fafb", // gray-50
            inverted: "#000000", // black
          },
        },
      },
      boxShadow: {
        // light
        "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
        "tremor-card": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
        "tremor-dropdown": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
        // dark
        "dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
        "dark-tremor-card": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
        "dark-tremor-dropdown": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
      },
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
        "tremor-small": "0.375rem",
        "tremor-default": "0.5rem",
        "tremor-full": "9999px",
      },
      fontSize: {
        "tremor-label": ["0.75rem"],
        "tremor-default": ["0.875rem", { lineHeight: "1.25rem" }],
        "tremor-title": ["1.125rem", { lineHeight: "1.75rem" }],
        "tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }],
      },
      keyframes: {
        "accordion-down": {
          from: { height: 0 },
          to: { height: "var(--radix-accordion-content-height)" },
        },
        "accordion-up": {
          from: { height: "var(--radix-accordion-content-height)" },
          to: { height: 0 },
        },
      },
      animation: {
        "accordion-down": "accordion-down 0.2s ease-out",
        "accordion-up": "accordion-up 0.2s ease-out",
      },
    },
  },
  safelist: [
    {
      pattern: /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
      variants: ["hover", "ui-selected"],
    },
    {
      pattern: /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
      variants: ["hover", "ui-selected"],
    },
    {
      pattern: /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
      variants: ["hover", "ui-selected"],
    },
    {
      pattern: /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
    },
    {
      pattern: /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
    },
    {
      pattern: /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
    },
  ],
  plugins: [require("tailwindcss-animate")],
}

参考ドキュメント:
https://www.tremor.so/docs/getting-started/theming

これで環境構築は終わりです。早速、実装に移っていきましょう!

🚀 実装手順

コンポーネントごとのサンプルコードです。
1つずつ紹介していきます!

Line Chartコンポーネント(売上高)

TermorのLine Chart(折れ線グラフ)を用いて実装しています。

card-sales.tsx
"use client"

import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { LineChart, AreaChart } from "@tremor/react"

export default function CardSales() {
  const chartdata3 = [
    {
      date: "11/19",
      sales: 0,
    },
    {
      date: "11/20",
      sales: 50000,
    },
    {
      date: "11/21",
      sales: 100000,
    },
    {
      date: "11/22",
      sales: 10000,
    },
    {
      date: "11/23",
      sales: 0,
    },
    {
      date: "11/24",
      sales: 0,
    },
    {
      date: "11/25",
      sales: 75000,
    },
  ]

  return (
    <div className="w-full">
      <Card>
        <CardHeader>
          <CardDescription className="text-[13px] text-gray-500">今週の総売上高</CardDescription>
          <CardTitle className="text-[28px] font-normal tracking-wider">¥ 240,000</CardTitle>
        </CardHeader>
        <CardContent>
          <div className="flex flex-row gap-8">
            {/* 売上Chart */}
            <LineChart className="h-72 max-w-[700px] w-full" data={chartdata3} index="date" categories={["sales"]} colors={["violet"]} yAxisWidth={50} minValue={0} maxValue={240000} />
            {/* 売上数値 */}
            <div className="max-w-[300px] w-full flex flex-col">
              <div className="border-b-[1px] mb-4 pb-2">
                <p className="text-[13px] text-gray-400">3級売上高</p>
                <p className="text-[21px] tracking-wider">¥ 10,000</p>
              </div>
              <div className="border-b-[1px] mb-4 pb-2">
                <p className="text-[13px] text-gray-400">2級売上高</p>
                <p className="text-[21px] tracking-wider">¥ 10,000</p>
              </div>
              <div className="border-b-[1px] mb-4 pb-2">
                <p className="text-[13px] text-gray-400">3&2級売上高</p>
                <p className="text-[21px] tracking-wider">¥ 10,000</p>
              </div>
            </div>
          </div>
        </CardContent>
      </Card>
    </div>
  )
}

Bar Chartコンポーネント(獲得ユーザー数)

TermorのBar Chart(棒グラフ)を用いて実装しています。

card-user.tsx
"use client"

import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { BarChart } from "@tremor/react"

export default function CardUser() {
  const chartdata = [
    {
      name: "3級",
      user: 4,
    },
    {
      name: "2級",
      user: 3,
    },
    {
      name: "3級&2級",
      user: 1,
    },
  ]

  const valueFormatter = (number: number) => `${new Intl.NumberFormat("us").format(number).toString()}`
  return (
    <div className="max-w-[500px] w-full">
      <Card>
        <CardHeader>
          <CardDescription className="text-[13px] text-gray-500">今週の獲得ユーザー数</CardDescription>
          <CardTitle className="text-[28px] font-normal tracking-wider">19</CardTitle>
        </CardHeader>
        <CardContent>
          <BarChart className="h-72 w-full" data={chartdata} index="name" categories={["user"]} colors={["violet"]} valueFormatter={valueFormatter} yAxisWidth={56} minValue={0} maxValue={20} showAnimation />
        </CardContent>
      </Card>
    </div>
  )
}

Bar Listコンポーネント(利用サービス)

TermorのBar Listを用いて実装しています。

card-funda-service.tsx
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { BarList } from "@tremor/react"

export default function CardFundaService() {
  const data = [
    {
      name: "Funda",
      value: 3,
    },
    {
      name: "Funda Navi",
      value: 4,
    },
    {
      name: "書籍",
      value: 4,
    },
    {
      name: "ファイナンスラボ",
      value: 4,
    },
    {
      name: "Twitter",
      value: 4,
    },
    {
      name: "Instagram",
      value: 4,
    },
    {
      name: "Voicy",
      value: 4,
    },
    {
      name: "LINE",
      value: 4,
    },
  ]
  return (
    <div className="max-w-[300px] w-full">
      <Card>
        <CardHeader>
          <CardDescription className="text-[13px] text-gray-500">Funda利用サービス</CardDescription>
        </CardHeader>
        <CardContent>
          <BarList className="font-bold" data={data} color={"violet"} />
        </CardContent>
      </Card>
    </div>
  )
}

Progress Circleコンポーネント(KPI)

TermorのProgress Circleを用いて実装しています。

card-kpi.tsx
"use client"

import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { ProgressCircle } from "@tremor/react"

export default function CardKPI() {
  return (
    <div className="max-w-[300px] w-full">
      <Card>
        <CardHeader>
          <CardDescription className="text-[13px] text-gray-500">KPI達成率</CardDescription>
          <CardTitle className="text-[28px] font-normal tracking-wider">50%</CardTitle>
        </CardHeader>
        <CardContent>
          <ProgressCircle value={50} strokeWidth={12} size="lg" color={"violet"}>
            <span className="text-[16px] font-bold text-gray-700">50%</span>
          </ProgressCircle>
        </CardContent>
      </Card>
    </div>
  )
}

Scatter Chartコンポーネント(アクティブ時刻)

TermorのScatter Chart(散布図)を用いて実装しています。

card-user-action,tsx
"use client"

import { ScatterChart } from "@tremor/react"

export default function CardUserAction() {
  const chartdata2 = [
    {
      location: "Location A",
      x: 18,
      y: 30,
      z: 100,
    },
    {
      location: "Location A",
      x: 9,
      y: 10,
      z: 100,
    },
    {
      location: "Location A",
      x: 10,
      y: 20,
      z: 100,
    },
    {
      location: "Location A",
      x: 21,
      y: 60,
      z: 100,
    },
    {
      location: "Location A",
      x: 10,
      y: 30,
      z: 100,
    },
    {
      location: "Location A",
      x: 10,
      y: 50,
      z: 100,
    },
    {
      location: "Location A",
      x: 8.5,
      y: 30,
      z: 100,
    },
    {
      location: "Location A",
      x: 8.5,
      y: 15,
      z: 100,
    },
    {
      location: "Location A",
      x: 8.7,
      y: 15,
      z: 100,
    },
    {
      location: "Location A",
      x: 9.2,
      y: 15,
      z: 100,
    },
    {
      location: "Location A",
      x: 9.6,
      y: 15,
      z: 100,
    },
    {
      location: "Location A",
      x: 10.8,
      y: 15,
      z: 100,
    },
  ]

  const customTooltip = ({ payload, active, label }: { payload: any; active: any; label: any }) => {
    if (!active || !payload) return null
    return (
      <div className="w-48 rounded-tremor-default text-tremor-default bg-tremor-background p-2 shadow-tremor-dropdown border border-tremor-border">
        <div className="flex flex-1 space-x-2.5">
          <div className={`w-1.5 flex flex-col bg-${payload[0]?.color}-500 rounded`} />
          <div className="w-full">
            <p className="mb-2 font-medium text-tremor-content-emphasis">{label}</p>
            {payload.map((payloadItem: any, index: any) => (
              <div key={index} className="flex items-center justify-between space-x-6">
                <span className="text-tremor-content">{payloadItem.name}</span>
                <span className="font-medium tabular-nums text-tremor-content-emphasis">{payloadItem.value}</span>
              </div>
            ))}
          </div>
        </div>
      </div>
    )
  }

  return (
    <div className="max-w-[800px] w-full">
      <div className="mb-4">
        <p className="font-[14px] text-gray-500">山田花子さんのアクティブ時刻(2023年11月19日~11月25日)</p>
      </div>
      <ScatterChart
        className="h-80"
        yAxisWidth={50}
        data={chartdata2}
        category="location"
        x="x"
        y="y"
        size="z"
        sizeRange={[1, 100]}
        colors={["violet", "purple"]}
        minXValue={0}
        maxXValue={24}
        minYValue={0}
        maxYValue={120}
        showAnimation
        showLegend={false}
        customTooltip={customTooltip}
        valueFormatter={{ x: x => `${x}`, y: y => `${y}` }}
      />
    </div>
  )
}

👾 おわり

Reactのチャートライブラリは他にも多く存在するのですが、使用感としてはTremorが一番いいかなと個人的に思いました!

NextjsのApp Routerについても再度ドキュメントを読み直したのですが、やはり難しいですね😅

👀 おまけ

弊社では、スマホやPC1つで完結する網羅的な教材と、無制限で解ける本番と同形式の模試で、短期間での資格取得を目指すことができる簿記のアプリ 『Funda簿記』 を運営しています。

少しでも興味のある方がいれば、リンクよりアクセスしていただくか、メールにてお願いします☺️

https://boki.funda.jp/

Discussion