【演習】YouTubeのUIを模写して再現する
この節でやること
今回は、YouTube風のUIを、アイコンとサムネイル付きで再現する演習です。
ただし、今回は border を使いません。
枠線で区切るのではなく、余白・背景色・角丸・文字の強弱 で画面を整理します。
やることは5つです。
- Material Symbols のアイコンを表示する
- YouTube の動画IDからサムネイル画像を表示する
- ヘッダーを作る
- サイドバーを作る
- 動画カードを並べて、YouTube風の一覧画面にする
今日作る画面の形
画面は大きく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
表で整理するとこうです。
| 使うもの | 例 |
|---|---|
| 動画ID | dQw4w9WgXcQ |
| サムネイルURL | https://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 を見る
Sidebar は左のメニューです。
function Sidebar() {
return (
<aside className="hidden w-56 shrink-0 bg-white p-3 md:block">
...
</aside>
)
}
ここで大事なのはこれです。
| クラス | 役割 |
|---|---|
hidden | 最初は非表示 |
md:block | md以上で表示 |
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" />
解説
MaterialIcon の name に search を渡します。
問題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-2 | sm以上で2列 |
xl:grid-cols-3 | xl以上で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 に分ける |
