pwa-setup について
pwa-setupは、インストール可能なアプリ風のブラウザー体験を構成する開発スキルです。マニフェスト、モバイル表示、安全領域、インストールプロンプトを扱い、Next.jsの例も含みます。
主な機能
- 01Web App Manifestの設定
- 02モバイルメタデータの設定
- 03ノッチ端末の安全領域対応
- 04インストールプロンプトのHookとバナー
- 05PWAアイコンの要件
活用シーン
- 01Webアプリのインストール対応
- 02モバイルユーザー向けホーム画面追加
- 03ノッチ端末向けレイアウト調整
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ dadbodgeoff/ drift --skill pwa-setupインストール後、Claude Code で/pwa-setupを入力して読み込まれたか確認します。
詳細
PWA のセットアップ
アプリのようなブラウザー体験を実現するためのプログレッシブウェブアプリの設定です。
このスキルを使う場面
- ユーザーがウェブアプリを「インストール」したい場合
- モバイルユーザーがホーム画面からアクセスしたい場合
- ネイティブアプリを使わずに、アプリのような動作を実現したい場合
- ノッチのあるデバイス(iPhone など)に対応する場合
基本概念
PWA には以下が必要です。
- ウェブアプリマニフェスト - アプリのメタデータとアイコン
- モバイル向けメタタグ - ビューポートとテーマの設定
- セーフエリア - ノッチのあるデバイスへの対応
- インストールプロンプト - カスタムのインストール体験
実装
TypeScript(Next.js)
// app/manifest.ts
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: "My SaaS App",
short_name: "MySaaS",
description: "Your app description here",
start_url: '/dashboard',
display: 'standalone',
background_color: '#0f172a',
theme_color: '#14b8a6',
orientation: 'portrait-primary',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/icons/icon-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
shortcuts: [
{ name: 'Dashboard', url: '/dashboard', description: 'Go to dashboard' },
{ name: 'Settings', url: '/settings', description: 'App settings' },
],
categories: ['productivity', 'utilities'],
};
}
ルートレイアウトのメタデータ
// app/layout.tsx
import type { Metadata, Viewport } from 'next';
export const metadata: Metadata = {
title: "My SaaS App",
description: "Your app description",
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: "MySaaS",
},
applicationName: "MySaaS",
openGraph: {
title: "My SaaS App",
description: "Your app description",
type: 'website',
siteName: "MySaaS",
},
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: '#14b8a6',
viewportFit: 'cover',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-neutral-900 text-neutral-cream min-h-screen">
{children}
</body>
</html>
);
}
セーフエリアの CSS
/* globals.css */
/* Safe area for bottom navigation (notched devices) */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
/* Safe area for top header */
.safe-area-top {
padding-top: env(safe-area-inset-top, 0);
}
/* Full safe area padding */
.safe-area-all {
padding-top: env(safe-area-inset-top, 0);
padding-right: env(safe-area-inset-right, 0);
padding-bottom: env(safe-area-inset-bottom, 0);
padding-left: env(safe-area-inset-left, 0);
}
インストールプロンプトのフック
// hooks/useInstallPrompt.ts
'use client';
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function useInstallPrompt() {
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
return;
}
const handler = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const promptInstall = async () => {
if (!installPrompt) return false;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') {
setIsInstalled(true);
setInstallPrompt(null);
}
return outcome === 'accepted';
};
return {
canInstall: !!installPrompt && !isInstalled,
isInstalled,
promptInstall,
};
}
インストールバナーコンポーネント
// components/InstallBanner.tsx
'use client';
import { useInstallPrompt } from '@/hooks/useInstallPrompt';
export function InstallBanner() {
const { canInstall, promptInstall } = useInstallPrompt();
if (!canInstall) return null;
return (
<div className="fixed bottom-20 left-4 right-4 bg-primary-600 text-white p-4 rounded-lg shadow-lg md:hidden">
<p className="text-sm mb-2">Install our app for a better experience</p>
<button
onClick={promptInstall}
className="w-full py-2 bg-white text-primary-600 rounded font-medium"
>
Install App
</button>
</div>
);
}
セーフエリアに対応したモバイルナビゲーション
// components/MobileNav.tsx
export function MobileNav() {
return (
<nav className="fixed bottom-0 left-0 right-0 bg-neutral-800 border-t border-neutral-700 z-30 md:hidden safe-area-bottom">
<div className="flex justify-around py-2">
<NavItem href="/dashboard" icon={HomeIcon} label="Home" />
<NavItem href="/search" icon={SearchIcon} label="Search" />
<NavItem href="/settings" icon={SettingsIcon} label="Settings" />
</div>
</nav>
);
}
アイコンの要件
public/
├── icons/
│ ├── icon-192.png # Standard icon
│ ├── icon-512.png # Large icon
│ └── icon-maskable.png # Maskable (with safe zone padding)
├── favicon.ico
└── apple-touch-icon.png # 180x180 for iOS
マスカブルアイコンのセーフゾーン
┌─────────────────────┐
│ │
│ ┌───────────┐ │
│ │ LOGO │ │ ← Content in center 80%
│ └───────────┘ │
│ │
└─────────────────────┘
使用例
PWA のテスト
- Chrome DevTools → アプリケーション → マニフェスト
- Lighthouse → PWA 監査
- モバイル → ホーム画面に追加
スタンドアロンモードの検出
function isStandalone(): boolean {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true;
}
ベストプラクティス
- セーフゾーン付きのマスカブルアイコンを使う
- ブランドに合うように theme_color を設定する
- ノッチのあるデバイス向けにセーフエリアを処理する
- 適切なタイミングでインストールプロンプトを表示する
- 実際のモバイルデバイスでテストする
よくある間違い
- マスカブルアイコンがない(Android で見栄えが悪くなる)
- セーフエリアへの対応がない(ノッチの下にコンテンツが表示される)
- インストールプロンプトをすぐに表示する(煩わしい)
- start_url が間違っている(別のページが開く)
- apple-touch-icon がない(iOS のフォールバック)
関連パターン
- design-tokens - 一貫性のあるテーマ設定
- mobile-components - レスポンシブコンポーネント
コメント
Markdown 対応