Next.jsによるWebアプリケーション開発概論

【演習】YouTubeのUIを模写して再現する

8画面設計基礎:レイアウトを組み、スタイルで整える
Next.jsTailwind CSSアプリ開発開発Web開発

この節でやること

今回は、YouTube風のUIを、アイコンとサムネイル付きで再現する演習です。

ただし、今回は border を使いません。

枠線で区切るのではなく、余白・背景色・角丸・文字の強弱 で画面を整理します。

やることは5つです。

  1. Material Symbols のアイコンを表示する
  2. YouTube の動画IDからサムネイル画像を表示する
  3. ヘッダーを作る
  4. サイドバーを作る
  5. 動画カードを並べて、YouTube風の一覧画面にする

今日作る画面の形

Rendering diagram…

画面は大きく3つです。

部品役割
Headerロゴ、検索欄、通知、アカウントを置く
Sidebarホーム、ショート、登録チャンネルなどを置く
VideoCardサムネイル、タイトル、チャンネル名、視聴情報を置く

事前準備1 Material Symbols を使えるようにする

Material Symbols を使うために、app/layout.tsx に Google Fonts の読み込みを追加します。

import type { Metadata } from 'next'
import './globals.css'

export const metadata: Metadata = {
  title: 'YouTube風UI演習',
  description: 'Next.jsとTailwind CSSでYouTube風UIを作る演習',
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="ja">
      <head>
        <link
          href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined"
          rel="stylesheet"
        />
      </head>
      <body>{children}</body>
    </html>
  )
}

事前準備2 YouTubeサムネイルを表示できるようにする

Next.js の Image コンポーネントで YouTube のサムネイル画像を表示するために、next.config.ts に設定を追加します。

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'img.youtube.com',
      },
    ],
  },
}

export default nextConfig

今日使う考え方

YouTube の動画URLには、動画IDがあります。

たとえば、次のようなURLです。

https://www.youtube.com/watch?v=dQw4w9WgXcQ

この中の動画IDはこれです。

dQw4w9WgXcQ

サムネイル画像は、次の形で作れます。

https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg

表で整理するとこうです。

使うもの
動画IDdQw4w9WgXcQ
サムネイルURLhttps://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg
画質指定hqdefault.jpg

完成コード

以下を app/page.tsx に書きます。

今回は border を削除し、背景色・余白・角丸で区切る形にしています。

import Image from 'next/image'

type MaterialIconProps = {
  name: string
  className?: string
}

type Video = {
  id: string
  title: string
  channel: string
  views: string
  videoId: string
}

type VideoCardProps = {
  video: Video
}

function MaterialIcon({ name, className = '' }: MaterialIconProps) {
  return (
    <span className={`material-symbols-outlined ${className}`}>{name}</span>
  )
}

function getYouTubeThumbnailUrl(videoId: string): string {
  return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
}

function Header() {
  return (
    <header className="flex items-center justify-between bg-white px-4 py-3">
      <div className="flex items-center gap-4">
        <button className="rounded-full p-2 hover:bg-gray-100">
          <MaterialIcon name="menu" className="text-2xl" />
        </button>

        <div className="flex items-center gap-1">
          <MaterialIcon name="play_circle" className="text-3xl text-gray-900" />
          <span className="text-lg font-bold">MyTube</span>
        </div>
      </div>

      <div className="hidden w-full max-w-xl items-center md:flex">
        <input
          className="h-10 w-full rounded-l-full bg-gray-100 px-4 text-sm outline-none"
          placeholder="検索"
        />
        <button className="flex h-10 w-16 items-center justify-center rounded-r-full bg-gray-100 hover:bg-gray-200">
          <MaterialIcon name="search" className="text-xl" />
        </button>
      </div>

      <div className="flex items-center gap-2">
        <button className="rounded-full p-2 hover:bg-gray-100">
          <MaterialIcon name="notifications" className="text-2xl" />
        </button>
        <button className="rounded-full p-2 hover:bg-gray-100">
          <MaterialIcon name="account_circle" className="text-3xl" />
        </button>
      </div>
    </header>
  )
}

function Sidebar() {
  return (
    <aside className="hidden w-56 shrink-0 bg-white p-3 md:block">
      <nav className="space-y-1">
        <div className="flex items-center gap-3 rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium">
          <MaterialIcon name="home" className="text-xl" />
          <span>ホーム</span>
        </div>

        <div className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-gray-100">
          <MaterialIcon name="smart_display" className="text-xl" />
          <span>ショート</span>
        </div>

        <div className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-gray-100">
          <MaterialIcon name="subscriptions" className="text-xl" />
          <span>登録チャンネル</span>
        </div>

        <div className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-gray-100">
          <MaterialIcon name="history" className="text-xl" />
          <span>履歴</span>
        </div>
      </nav>
    </aside>
  )
}

function CategoryBar() {
  const categories: string[] = [
    'すべて',
    '音楽',
    'ゲーム',
    'プログラミング',
    'ニュース',
    '学習',
  ]

  return (
    <div className="mb-6 flex gap-2 overflow-x-auto">
      {categories.map((category, index) => (
        <button
          key={category}
          className={
            index === 0
              ? 'shrink-0 rounded-full bg-black px-4 py-2 text-sm text-white'
              : 'shrink-0 rounded-full bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300'
          }
        >
          {category}
        </button>
      ))}
    </div>
  )
}

function VideoCard({ video }: VideoCardProps) {
  const thumbnailUrl = getYouTubeThumbnailUrl(video.videoId)

  return (
    <article className="space-y-3">
      <div className="relative aspect-video overflow-hidden rounded-xl bg-gray-200">
        <Image
          src={thumbnailUrl}
          alt={`${video.title}のサムネイル`}
          fill
          sizes="(max-width: 640px) 100vw, (max-width: 1280px) 50vw, 33vw"
          className="object-cover"
        />
      </div>

      <div className="flex gap-3">
        <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-300">
          <MaterialIcon
            name="account_circle"
            className="text-2xl text-gray-600"
          />
        </div>

        <div className="min-w-0">
          <h2 className="line-clamp-2 text-sm font-semibold leading-5">
            {video.title}
          </h2>
          <p className="mt-1 text-xs text-gray-600">{video.channel}</p>
          <p className="text-xs text-gray-500">{video.views}</p>
        </div>
      </div>
    </article>
  )
}

export default function Page() {
  const videos: Video[] = [
    {
      id: '1',
      title: 'Next.js と Tailwind CSS で動画一覧画面を作る',
      channel: 'Frontend Lab',
      views: '12万回視聴・3日前',
      videoId: 'dQw4w9WgXcQ',
    },
    {
      id: '2',
      title: 'UI模写でレイアウト感覚を身につける',
      channel: 'Design Study',
      views: '8.4万回視聴・1週間前',
      videoId: 'M7lc1UVf-VE',
    },
    {
      id: '3',
      title: 'レスポンシブデザインの基本を学ぶ',
      channel: 'Code Room',
      views: '5.2万回視聴・2日前',
      videoId: 'ysz5S6PUM-U',
    },
    {
      id: '4',
      title: 'Flexbox と Grid を使い分ける',
      channel: 'Layout Note',
      views: '3.8万回視聴・5日前',
      videoId: 'jNQXAC9IVRw',
    },
    {
      id: '5',
      title: 'カード型UIを素早く作る方法',
      channel: 'UI Practice',
      views: '9.1万回視聴・4日前',
      videoId: 'ScMzIvxBSi4',
    },
    {
      id: '6',
      title: 'Tailwind CSS の基本クラスを覚える',
      channel: 'Web Class',
      views: '6.6万回視聴・6日前',
      videoId: 'aqz-KE-bpKQ',
    },
  ]

  return (
    <main className="min-h-screen bg-gray-50">
      <Header />

      <div className="flex">
        <Sidebar />

        <section className="flex-1 p-4 md:p-6">
          <CategoryBar />

          <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
            {videos.map((video) => (
              <VideoCard key={video.id} video={video} />
            ))}
          </div>
        </section>
      </div>
    </main>
  )
}

手を動かす 1

MaterialIcon を理解する

まず見る場所はここです。

type MaterialIconProps = {
  name: string
  className?: string
}

function MaterialIcon({ name, className = '' }: MaterialIconProps) {
  return (
    <span className={`material-symbols-outlined ${className}`}>{name}</span>
  )
}

これは、Material Symbols のアイコンを表示するための小さな部品です。

使い方はこうです。

<MaterialIcon name="menu" />
<MaterialIcon name="search" />
<MaterialIcon name="home" />

アイコン名を表で確認する

表示したいもの書く名前
メニューmenu
検索search
ホームhome
ショート動画smart_display
登録チャンネルsubscriptions
履歴history
通知notifications
アカウントaccount_circle
再生play_circle

手を動かす 2

サムネイルURLを理解する

見る場所はここです。

function getYouTubeThumbnailUrl(videoId: string): string {
  return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
}

この関数は、YouTube の動画IDからサムネイル画像URLを作っています。

たとえば、動画IDがこれなら、

dQw4w9WgXcQ

作られるURLはこれです。

https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg

サムネイルの仕組み

項目内容
入力YouTubeの動画ID
処理URLの中に動画IDを入れる
出力サムネイル画像のURL

手を動かす 3

Header を見る

Header は、画面上部のメニューです。

function Header() {
  return (
    <header className="flex items-center justify-between bg-white px-4 py-3">
      ...
    </header>
  )
}

ここで大事なのはこの3つです。

クラス役割
flex横並びにする
items-center縦方向を中央にそろえる
justify-between左・中央・右に分ける

ヘッダーの中身は3つです。

場所内容
メニューアイコン、ロゴ
中央検索欄
通知、アカウント

手を動かす 4

Sidebar は左のメニューです。

function Sidebar() {
  return (
    <aside className="hidden w-56 shrink-0 bg-white p-3 md:block">
      ...
    </aside>
  )
}

ここで大事なのはこれです。

クラス役割
hidden最初は非表示
md:blockmd以上で表示
w-56横幅を決める
shrink-0幅がつぶれないようにする
p-3内側の余白

スマホではサイドバーを隠し、広い画面だけ表示します。

これで画面が狭いときも見やすくなります。

手を動かす 5

CategoryBar を見る

CategoryBar は、横に並ぶ丸いカテゴリボタンです。

function CategoryBar() {
  const categories: string[] = [
    'すべて',
    '音楽',
    'ゲーム',
    'プログラミング',
    'ニュース',
    '学習',
  ]

  return (
    <div className="mb-6 flex gap-2 overflow-x-auto">
      ...
    </div>
  )
}

ここで大事なのはこれです。

クラス役割
flex横並び
gap-2ボタン同士の間隔
overflow-x-auto横に長いときスクロール
shrink-0ボタンが細く潰れない

カテゴリボタンは、画面が狭いと横にあふれます。

そのため、overflow-x-auto を使って横スクロールできるようにしています。

手を動かす 6

VideoCard を見る

VideoCard は、動画1件分の部品です。

function VideoCard({ video }: VideoCardProps) {
  const thumbnailUrl = getYouTubeThumbnailUrl(video.videoId)

  return (
    <article className="space-y-3">
      ...
    </article>
  )
}

中身は3つです。

部分内容
サムネイルImage で表示
アイコン丸いチャンネル画像風
テキストタイトル、チャンネル名、視聴情報

VideoCard の重要なクラス

クラス役割
aspect-video動画らしい横長比率にする
overflow-hiddenはみ出た画像を隠す
rounded-xlサムネイルの角を丸くする
object-cover画像を枠いっぱいに表示する
line-clamp-2タイトルを2行までにする
min-w-0長い文字でレイアウトが崩れにくくする

手を動かす 7

動画一覧を作る

最後に、Page の中で動画一覧を作っています。

<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
  {videos.map((video) => (
    <VideoCard key={video.id} video={video} />
  ))}
</div>

ここで大事なのは、列数です。

クラス画面幅列数
grid-cols-1基本1列
sm:grid-cols-2少し広い画面2列
xl:grid-cols-3かなり広い画面3列

レスポンシブ対応は、このように書きます。

borderなしでどう区切っているか

今回は border を削除しています。

その代わりに、次のもので画面を区切っています。

代わりに使うもの役割
bg-white背景を白にして面を作る
bg-gray-50ページ背景を薄くする
bg-gray-100選択中メニューを目立たせる
rounded-*角丸で部品感を出す
gap-*要素同士を離す
p-*内側の余白を作る
hover:bg-*触れる場所を分かりやすくする

枠線がなくても、余白と背景だけでかなり整理できます。

練習問題

問題1

検索アイコンを表示してください。

答え

<MaterialIcon name="search" />

解説

MaterialIconnamesearch を渡します。

問題2

動画IDが dQw4w9WgXcQ のとき、サムネイルURLを作ってください。

答え

https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg

解説

形はこれです。

https://img.youtube.com/vi/動画ID/hqdefault.jpg

問題3

スマホで1列、sm以上で2列、xl以上で3列にするクラスを書いてください。

答え

className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3"

解説

クラス意味
grid-cols-1基本は1列
sm:grid-cols-2sm以上で2列
xl:grid-cols-3xl以上で3列

問題4

borderを使わずに、カードらしく見せるには何を使いますか。

答え

className="rounded-xl bg-white p-4"

解説

枠線がなくても、背景色・角丸・余白でカードらしく見せられます。

まとめ

今回は、YouTube風のUIを borderなし で作りました。

大事なのは、本物を丸暗記することではありません。

大事なのは、画面を部品に分けることです。

部品作ったもの
Header上のメニュー
Sidebar左のメニュー
CategoryBarカテゴリボタン
VideoCard動画カード
Page全体の組み立て

今回のポイントは3つです。

ポイント内容
アイコンMaterial Symbols を使う
サムネイルYouTube動画IDから画像URLを作る
レイアウトHeader、Sidebar、VideoCard に分ける
教材トップへ戻る