Svelte

コンパイル時最適化によるWebフレームワーク。ビルド時にバニラJavaScriptにコンパイルし、実行時の仮想DOMが不要なため高性能。

JavaScriptフレームワークコンパイラリアクティブパフォーマンスコンポーネントUI

GitHub概要

sveltejs/svelte

web development for the rest of us

ホームページ:https://svelte.dev
スター83,714
ウォッチ852
フォーク4,591
作成日:2016年11月20日
言語:JavaScript
ライセンス:MIT License

トピックス

compilertemplateui

スター履歴

sveltejs/svelte Star History
データ取得日時: 2025/8/13 01:43

フレームワーク

Svelte

概要

Svelteは、コンパイル時に最適化された高性能なJavaScriptコードを生成する革新的なWebコンポーネントフレームワークです。Virtual DOMやランタイムオーバーヘッドがありません。

詳細

Svelte(スベルト)は、2016年にRich Harrisによって開発されたWebアプリケーションフレームワークで、従来のReactやVue.jsとは根本的に異なるアプローチを採用しています。Svelteはコンパイラフレームワークであり、ビルド時にコンポーネントを最適化されたバニラJavaScriptに変換し、Virtual DOMやランタイムフレームワークを必要としません。特徴として、簡潔で直感的な構文、リアクティブな状態管理、コンポーネントスコープCSS、アニメーションとトランジションの内蔵サポート、小さなバンドルサイズ、高いパフォーマンス、TypeScriptサポート、アクセシビリティの組み込みサポートなどがあります。Svelte 5ではルーン(Runes)システムが導入され、$state、$derived、$effect、$propsなどの新しいリアクティビティシステムが導入されました。特にパフォーマンスを重視するWebアプリケーション、インタラクティブダッシュボード、データ可視化、モバイルアプリケーション開発で採用され、The New York Times、Apple、スポティファイ、イケアなどの大手企業でも使用されています。

メリット・デメリット

メリット

  • コンパイル時最適化: Virtual DOM不要で高速動作
  • 小さなバンドルサイズ: ランタイムオーバーヘッドがほぼゼロ
  • 簡潔な構文: 学習しやすい直感的な書き方
  • リアクティブな状態管理: 複雑な状態管理ライブラリ不要
  • コンポーネントスコープCSS: CSSのスコープが自動管理
  • 組み込みアニメーション: 美しいトランジションを簡単に実現
  • TypeScriptサポート: 優秀な型推論と開発体験
  • アクセシビリティ: a11yチェックと警告機能内蔵

デメリット

  • エコシステムの小ささ: React/Vue.jsと比較してライブラリが少ない
  • コンパイルステップ: ビルドプロセスが必須
  • デバッグの難しさ: コンパイル後コードのデバッグが困難
  • コミュニティサイズ: 他のメジャーフレームワークと比較して小さい
  • サーバーサイドレンダリング: 基本的にクライアントサイドフレームワーク
  • 学習リソース: 日本語の情報源が限定的
  • 既存プロジェクト移行: 他フレームワークからの移行コスト

主要リンク

書き方の例

Hello World

<!-- App.svelte -->
<script>
  // JavaScriptロジック
  let name = 'Svelte';
  let count = 0;
  
  // リアクティブステートメント
  $: doubled = count * 2;
  $: if (count >= 10) {
    alert('カウントが10に達しました!');
  }
  
  // イベントハンドラー
  function increment() {
    count += 1;
  }
  
  function decrement() {
    count -= 1;
  }
  
  function reset() {
    count = 0;
  }
</script>

<!-- HTMLテンプレート -->
<main>
  <h1>Hello {name}!</h1>
  
  <div class="counter">
    <p>カウント: {count}</p>
    <p>倖数: {doubled}</p>
    
    <div class="buttons">
      <button on:click={decrement}>-</button>
      <button on:click={reset}>リセット</button>
      <button on:click={increment}>+</button>
    </div>
  </div>
  
  <!-- 条件付きレンダリング -->
  {#if count > 5}
    <p class="warning">カウントが高いです!</p>
  {:else if count < 0}
    <p class="error">カウントがマイナスです!</p>
  {/if}
</main>

<!-- コンポーネントスコープCSS -->
<style>
  main {
    max-width: 500px;
    margin: 0 auto;
    padding: 2rem;
    text-align: center;
    font-family: Arial, sans-serif;
  }
  
  h1 {
    color: #ff3e00;
    font-size: 2.5rem;
    margin-bottom: 2rem;
  }
  
  .counter {
    background: #f9f9f9;
    border-radius: 8px;
    padding: 1.5rem;
    margin: 1rem 0;
  }
  
  .counter p {
    font-size: 1.2rem;
    margin: 0.5rem 0;
  }
  
  .buttons {
    display: flex;
    gap: 1rem;
    justify-content: center;
    margin-top: 1rem;
  }
  
  button {
    background: #ff3e00;
    color: white;
    border: none;
    padding: 0.8rem 1.5rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1rem;
    transition: background-color 0.2s;
  }
  
  button:hover {
    background: #e03400;
  }
  
  .warning {
    color: #ff9500;
    font-weight: bold;
  }
  
  .error {
    color: #ff0000;
    font-weight: bold;
  }
</style>

コンポーネントとProps

<!-- UserCard.svelte -->
<script>
  // Propsの定義
  export let user;
  export let showEmail = true;
  export let size = 'medium';
  
  // イベントディスパッチャー
  import { createEventDispatcher } from 'svelte';
  const dispatch = createEventDispatcher();
  
  // ユーザーアクション
  function editUser() {
    dispatch('edit', {
      user: user
    });
  }
  
  function deleteUser() {
    dispatch('delete', {
      userId: user.id
    });
  }
  
  // 計算された値
  $: cardClass = `user-card ${size}`;
  $: avatarUrl = user.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.name)}&background=ff3e00&color=fff`;
</script>

<div class={cardClass}>
  <div class="avatar">
    <img src={avatarUrl} alt="{user.name}のアバター" />
  </div>
  
  <div class="user-info">
    <h3>{user.name}</h3>
    {#if showEmail}
      <p class="email">{user.email}</p>
    {/if}
    <span class="role" class:admin={user.role === 'admin'}>
      {user.role}
    </span>
  </div>
  
  <div class="actions">
    <button class="btn btn-primary" on:click={editUser}>
      編集
    </button>
    <button class="btn btn-danger" on:click={deleteUser}>
      削除
    </button>
  </div>
</div>

<style>
  .user-card {
    display: flex;
    align-items: center;
    gap: 1rem;
    padding: 1rem;
    border: 1px solid #e0e0e0;
    border-radius: 8px;
    background: white;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    transition: transform 0.2s, box-shadow 0.2s;
  }
  
  .user-card:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
  }
  
  .user-card.small {
    padding: 0.75rem;
    gap: 0.75rem;
  }
  
  .user-card.large {
    padding: 1.5rem;
    gap: 1.5rem;
  }
  
  .avatar img {
    width: 50px;
    height: 50px;
    border-radius: 50%;
    object-fit: cover;
  }
  
  .small .avatar img {
    width: 40px;
    height: 40px;
  }
  
  .large .avatar img {
    width: 60px;
    height: 60px;
  }
  
  .user-info {
    flex: 1;
    text-align: left;
  }
  
  .user-info h3 {
    margin: 0 0 0.5rem 0;
    color: #333;
  }
  
  .email {
    margin: 0 0 0.5rem 0;
    color: #666;
    font-size: 0.9rem;
  }
  
  .role {
    display: inline-block;
    padding: 0.25rem 0.5rem;
    background: #f0f0f0;
    border-radius: 4px;
    font-size: 0.8rem;
    color: #555;
  }
  
  .role.admin {
    background: #ff3e00;
    color: white;
  }
  
  .actions {
    display: flex;
    gap: 0.5rem;
  }
  
  .btn {
    padding: 0.5rem 1rem;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 0.9rem;
    transition: background-color 0.2s;
  }
  
  .btn-primary {
    background: #007bff;
    color: white;
  }
  
  .btn-primary:hover {
    background: #0056b3;
  }
  
  .btn-danger {
    background: #dc3545;
    color: white;
  }
  
  .btn-danger:hover {
    background: #a71e2a;
  }
</style>
<!-- UserList.svelte -->
<script>
  import UserCard from './UserCard.svelte';
  
  // ユーザーリスト
  let users = [
    {
      id: 1,
      name: '田中太郎',
      email: '[email protected]',
      role: 'admin',
      avatar: 'https://i.pravatar.cc/150?img=1'
    },
    {
      id: 2,
      name: '佐藤花子',
      email: '[email protected]',
      role: 'user',
      avatar: 'https://i.pravatar.cc/150?img=2'
    },
    {
      id: 3,
      name: '鈴木一郎',
      email: '[email protected]',
      role: 'user',
      avatar: 'https://i.pravatar.cc/150?img=3'
    }
  ];
  
  // イベントハンドラー
  function handleEditUser(event) {
    console.log('ユーザー編集:', event.detail.user);
    // 編集ロジックを実装
  }
  
  function handleDeleteUser(event) {
    const userId = event.detail.userId;
    if (confirm('このユーザーを削除しますか?')) {
      users = users.filter(user => user.id !== userId);
    }
  }
</script>

<div class="user-list">
  <h2>ユーザー一覧</h2>
  
  {#if users.length > 0}
    <div class="users-grid">
      {#each users as user (user.id)}
        <UserCard 
          {user} 
          on:edit={handleEditUser}
          on:delete={handleDeleteUser}
        />
      {/each}
    </div>
  {:else}
    <p class="no-users">ユーザーが見つかりません。</p>
  {/if}
</div>

<style>
  .user-list {
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
  }
  
  h2 {
    color: #333;
    margin-bottom: 2rem;
    text-align: center;
  }
  
  .users-grid {
    display: grid;
    gap: 1rem;
  }
  
  .no-users {
    text-align: center;
    color: #666;
    font-style: italic;
  }
</style>

ストアと状態管理

// stores/counter.js
import { writable, derived } from 'svelte/store';

// 基本的なストア
export const count = writable(0);

// 計算されたストア
export const doubled = derived(count, $count => $count * 2);
export const quadrupled = derived(doubled, $doubled => $doubled * 2);

// カスタムストア
function createCounter() {
  const { subscribe, set, update } = writable(0);
  
  return {
    subscribe,
    increment: () => update(n => n + 1),
    decrement: () => update(n => n - 1),
    reset: () => set(0),
    set: (value) => set(value)
  };
}

export const counter = createCounter();

// 非同期ストア
export const users = writable([]);

export async function loadUsers() {
  try {
    const response = await fetch('/api/users');
    const userData = await response.json();
    users.set(userData);
  } catch (error) {
    console.error('ユーザー読み込みエラー:', error);
  }
}

export async function addUser(newUser) {
  try {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(newUser)
    });
    
    if (response.ok) {
      const user = await response.json();
      users.update(userList => [...userList, user]);
      return user;
    }
  } catch (error) {
    console.error('ユーザー追加エラー:', error);
  }
}
<!-- ストア使用例 -->
<script>
  import { count, doubled, counter, users } from './stores/counter.js';
  import { onMount } from 'svelte';
  
  // ストアの購読
  $: console.log('カウントが変更されました:', $count);
  
  // コンポーネントマウント時の処理
  onMount(() => {
    console.log('コンポーネントがマウントされました');
    return () => {
      console.log('コンポーネントがアンマウントされました');
    };
  });
</script>

<div>
  <h3>ストアテスト</h3>
  
  <p>カウント: {$count}</p>
  <p>倖数: {$doubled}</p>
  
  <button on:click={counter.increment}>+1</button>
  <button on:click={counter.decrement}>-1</button>
  <button on:click={counter.reset}>リセット</button>
  
  <!-- 直接値を設定 -->
  <input type="number" bind:value={$count} />
</div>

アニメーションとトランジション

<script>
  import { fade, fly, scale, slide } from 'svelte/transition';
  import { flip } from 'svelte/animate';
  import { elasticOut } from 'svelte/easing';
  
  let visible = true;
  let items = [
    { id: 1, name: 'アイテム1' },
    { id: 2, name: 'アイテム2' },
    { id: 3, name: 'アイテム3' }
  ];
  
  function toggle() {
    visible = !visible;
  }
  
  function addItem() {
    const newId = Math.max(...items.map(i => i.id)) + 1;
    items = [...items, { id: newId, name: `アイテム${newId}` }];
  }
  
  function removeItem(id) {
    items = items.filter(item => item.id !== id);
  }
  
  function shuffle() {
    items = items.sort(() => Math.random() - 0.5);
  }
</script>

<div class="animation-demo">
  <h3>アニメーションデモ</h3>
  
  <div class="controls">
    <button on:click={toggle}>
      {visible ? '非表示' : '表示'}
    </button>
    <button on:click={addItem}>アイテム追加</button>
    <button on:click={shuffle}>シャッフル</button>
  </div>
  
  {#if visible}
    <div class="box" 
         in:fly="{{ y: 200, duration: 2000, easing: elasticOut }}"
         out:fade="{{ duration: 300 }}">
      フェードイン/アウトボックス
    </div>
  {/if}
  
  <div class="item-list">
    {#each items as item (item.id)}
      <div class="item"
           in:scale="{{ duration: 300 }}"
           out:slide="{{ duration: 300 }}"
           animate:flip="{{ duration: 300 }}">
        <span>{item.name}</span>
        <button on:click={() => removeItem(item.id)}>×</button>
      </div>
    {/each}
  </div>
</div>

<style>
  .animation-demo {
    max-width: 500px;
    margin: 0 auto;
    padding: 2rem;
  }
  
  .controls {
    display: flex;
    gap: 1rem;
    margin-bottom: 2rem;
  }
  
  .controls button {
    padding: 0.5rem 1rem;
    background: #ff3e00;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  }
  
  .box {
    background: linear-gradient(45deg, #ff3e00, #ff8c00);
    color: white;
    padding: 2rem;
    border-radius: 8px;
    text-align: center;
    margin: 2rem 0;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  }
  
  .item-list {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
  }
  
  .item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 1rem;
    background: #f9f9f9;
    border-radius: 4px;
    border: 1px solid #e0e0e0;
  }
  
  .item button {
    background: #dc3545;
    color: white;
    border: none;
    width: 24px;
    height: 24px;
    border-radius: 50%;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
  }
</style>

HTTPリクエストとデータ取得

<script>
  import { onMount } from 'svelte';
  
  let posts = [];
  let loading = false;
  let error = null;
  let newPost = { title: '', body: '' };
  
  // データ取得
  async function loadPosts() {
    loading = true;
    error = null;
    
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
      if (!response.ok) {
        throw new Error('データの取得に失敗しました');
      }
      posts = await response.json();
    } catch (err) {
      error = err.message;
    } finally {
      loading = false;
    }
  }
  
  // 新しい投稿を作成
  async function createPost() {
    if (!newPost.title.trim() || !newPost.body.trim()) {
      alert('タイトルと本文を入力してください');
      return;
    }
    
    loading = true;
    error = null;
    
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          title: newPost.title,
          body: newPost.body,
          userId: 1
        })
      });
      
      if (!response.ok) {
        throw new Error('投稿の作成に失敗しました');
      }
      
      const post = await response.json();
      posts = [post, ...posts];
      newPost = { title: '', body: '' };
    } catch (err) {
      error = err.message;
    } finally {
      loading = false;
    }
  }
  
  // 投稿削除
  async function deletePost(postId) {
    if (!confirm('この投稿を削除しますか?')) {
      return;
    }
    
    try {
      const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`, {
        method: 'DELETE'
      });
      
      if (response.ok) {
        posts = posts.filter(post => post.id !== postId);
      }
    } catch (err) {
      console.error('削除エラー:', err);
    }
  }
  
  // コンポーネントマウント時にデータを読み込み
  onMount(() => {
    loadPosts();
  });
</script>

<div class="posts-app">
  <h2>ブログ投稿管理</h2>
  
  <!-- 新しい投稿フォーム -->
  <form on:submit|preventDefault={createPost} class="post-form">
    <h3>新しい投稿</h3>
    <input 
      type="text" 
      bind:value={newPost.title} 
      placeholder="タイトル"
      disabled={loading}
    />
    <textarea 
      bind:value={newPost.body} 
      placeholder="本文"
      rows="4"
      disabled={loading}
    ></textarea>
    <button type="submit" disabled={loading}>
      {loading ? '作成中...' : '投稿作成'}
    </button>
  </form>
  
  <!-- エラー表示 -->
  {#if error}
    <div class="error">
      エラー: {error}
      <button on:click={loadPosts}>再試行</button>
    </div>
  {/if}
  
  <!-- ローディング表示 -->
  {#if loading}
    <div class="loading">読み込み中...</div>
  {/if}
  
  <!-- 投稿一覧 -->
  <div class="posts-list">
    {#each posts as post (post.id)}
      <article class="post">
        <h4>{post.title}</h4>
        <p>{post.body}</p>
        <div class="post-actions">
          <small>ID: {post.id} | User: {post.userId}</small>
          <button class="delete-btn" on:click={() => deletePost(post.id)}>
            削除
          </button>
        </div>
      </article>
    {/each}
    
    {#if posts.length === 0 && !loading && !error}
      <p class="no-posts">投稿がありません。</p>
    {/if}
  </div>
</div>

<style>
  .posts-app {
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
  }
  
  .post-form {
    background: #f9f9f9;
    padding: 1.5rem;
    border-radius: 8px;
    margin-bottom: 2rem;
  }
  
  .post-form input,
  .post-form textarea {
    width: 100%;
    padding: 0.75rem;
    margin-bottom: 1rem;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-family: inherit;
  }
  
  .post-form button {
    background: #ff3e00;
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1rem;
  }
  
  .post-form button:disabled {
    background: #ccc;
    cursor: not-allowed;
  }
  
  .error {
    background: #ffe6e6;
    color: #d00;
    padding: 1rem;
    border-radius: 4px;
    margin-bottom: 1rem;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }
  
  .error button {
    background: #d00;
    color: white;
    border: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    cursor: pointer;
  }
  
  .loading {
    text-align: center;
    padding: 2rem;
    color: #666;
    font-style: italic;
  }
  
  .posts-list {
    display: grid;
    gap: 1rem;
  }
  
  .post {
    background: white;
    padding: 1.5rem;
    border: 1px solid #e0e0e0;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  }
  
  .post h4 {
    margin: 0 0 0.5rem 0;
    color: #333;
  }
  
  .post p {
    margin: 0 0 1rem 0;
    color: #666;
    line-height: 1.5;
  }
  
  .post-actions {
    display: flex;
    justify-content: space-between;
    align-items: center;
  }
  
  .delete-btn {
    background: #dc3545;
    color: white;
    border: none;
    padding: 0.25rem 0.75rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 0.875rem;
  }
  
  .no-posts {
    text-align: center;
    color: #666;
    font-style: italic;
    padding: 3rem;
  }
</style>

フォームとバリデーション

<script>
  let formData = {
    name: '',
    email: '',
    age: '',
    gender: '',
    interests: [],
    newsletter: false,
    bio: ''
  };
  
  let errors = {};
  let touched = {};
  
  // バリデーションルール
  function validateField(field, value) {
    switch (field) {
      case 'name':
        return value.trim().length < 2 ? '名前は2文字以上で入力してください' : '';
      
      case 'email':
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        return !emailRegex.test(value) ? '有効なメールアドレスを入力してください' : '';
      
      case 'age':
        const ageNum = parseInt(value);
        return isNaN(ageNum) || ageNum < 1 || ageNum > 120 ? '有効な年齢を1-120で入力してください' : '';
      
      case 'gender':
        return !value ? '性別を選択してください' : '';
      
      default:
        return '';
    }
  }
  
  // リアルタイムバリデーション
  $: {
    errors = {};
    Object.keys(formData).forEach(field => {
      if (touched[field]) {
        const error = validateField(field, formData[field]);
        if (error) errors[field] = error;
      }
    });
  }
  
  // フォームの有効性チェック
  $: isValid = Object.keys(errors).length === 0 && 
              formData.name && 
              formData.email && 
              formData.age && 
              formData.gender;
  
  function handleFieldBlur(field) {
    touched[field] = true;
  }
  
  function handleInterestChange(interest) {
    if (formData.interests.includes(interest)) {
      formData.interests = formData.interests.filter(i => i !== interest);
    } else {
      formData.interests = [...formData.interests, interest];
    }
  }
  
  function handleSubmit() {
    // 全フィールドをtouched状態に
    Object.keys(formData).forEach(field => {
      touched[field] = true;
    });
    
    if (isValid) {
      console.log('フォーム送信:', formData);
      alert('フォームが正常に送信されました!');
    } else {
      alert('エラーを修正してください');
    }
  }
</script>

<div class="form-container">
  <h2>ユーザー登録フォーム</h2>
  
  <form on:submit|preventDefault={handleSubmit}>
    <!-- 名前 -->
    <div class="field">
      <label for="name">名前 *</label>
      <input 
        type="text" 
        id="name"
        bind:value={formData.name}
        on:blur={() => handleFieldBlur('name')}
        class:error={errors.name}
      />
      {#if errors.name}
        <span class="error-message">{errors.name}</span>
      {/if}
    </div>
    
    <!-- メール -->
    <div class="field">
      <label for="email">メールアドレス *</label>
      <input 
        type="email" 
        id="email"
        bind:value={formData.email}
        on:blur={() => handleFieldBlur('email')}
        class:error={errors.email}
      />
      {#if errors.email}
        <span class="error-message">{errors.email}</span>
      {/if}
    </div>
    
    <!-- 年齢 -->
    <div class="field">
      <label for="age">年齢 *</label>
      <input 
        type="number" 
        id="age"
        bind:value={formData.age}
        on:blur={() => handleFieldBlur('age')}
        class:error={errors.age}
        min="1" 
        max="120"
      />
      {#if errors.age}
        <span class="error-message">{errors.age}</span>
      {/if}
    </div>
    
    <!-- 性別 -->
    <div class="field">
      <label>性別 *</label>
      <div class="radio-group">
        <label class="radio-label">
          <input 
            type="radio" 
            bind:group={formData.gender} 
            value="male"
            on:change={() => handleFieldBlur('gender')}
          />
          男性
        </label>
        <label class="radio-label">
          <input 
            type="radio" 
            bind:group={formData.gender} 
            value="female"
            on:change={() => handleFieldBlur('gender')}
          />
          女性
        </label>
        <label class="radio-label">
          <input 
            type="radio" 
            bind:group={formData.gender} 
            value="other"
            on:change={() => handleFieldBlur('gender')}
          />
          その他
        </label>
      </div>
      {#if errors.gender}
        <span class="error-message">{errors.gender}</span>
      {/if}
    </div>
    
    <!-- 興味 -->
    <div class="field">
      <label>興味 (複数選択可)</label>
      <div class="checkbox-group">
        {#each ['programming', 'design', 'music', 'sports', 'reading'] as interest}
          <label class="checkbox-label">
            <input 
              type="checkbox" 
              checked={formData.interests.includes(interest)}
              on:change={() => handleInterestChange(interest)}
            />
            {interest}
          </label>
        {/each}
      </div>
    </div>
    
    <!-- ニュースレター -->
    <div class="field">
      <label class="checkbox-label">
        <input 
          type="checkbox" 
          bind:checked={formData.newsletter}
        />
        ニュースレターを購読する
      </label>
    </div>
    
    <!-- 自己紹介 -->
    <div class="field">
      <label for="bio">自己紹介</label>
      <textarea 
        id="bio"
        bind:value={formData.bio}
        rows="4"
        placeholder="自己紹介を書いてください..."
      ></textarea>
    </div>
    
    <!-- 送信ボタン -->
    <button type="submit" class:disabled={!isValid}>
      登録する
    </button>
  </form>
  
  <!-- フォームデータプレビュー -->
  <div class="preview">
    <h3>入力データプレビュー</h3>
    <pre>{JSON.stringify(formData, null, 2)}</pre>
  </div>
</div>

<style>
  .form-container {
    max-width: 600px;
    margin: 0 auto;
    padding: 2rem;
  }
  
  .field {
    margin-bottom: 1.5rem;
  }
  
  label {
    display: block;
    margin-bottom: 0.5rem;
    font-weight: bold;
    color: #333;
  }
  
  input[type="text"],
  input[type="email"],
  input[type="number"],
  textarea {
    width: 100%;
    padding: 0.75rem;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 1rem;
    transition: border-color 0.2s;
  }
  
  input:focus,
  textarea:focus {
    outline: none;
    border-color: #ff3e00;
  }
  
  input.error {
    border-color: #dc3545;
  }
  
  .error-message {
    color: #dc3545;
    font-size: 0.875rem;
    margin-top: 0.25rem;
    display: block;
  }
  
  .radio-group,
  .checkbox-group {
    display: flex;
    gap: 1rem;
    flex-wrap: wrap;
  }
  
  .radio-label,
  .checkbox-label {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-weight: normal;
    cursor: pointer;
  }
  
  button {
    background: #ff3e00;
    color: white;
    border: none;
    padding: 1rem 2rem;
    border-radius: 4px;
    font-size: 1rem;
    cursor: pointer;
    transition: background-color 0.2s;
  }
  
  button:hover:not(.disabled) {
    background: #e03400;
  }
  
  button.disabled {
    background: #ccc;
    cursor: not-allowed;
  }
  
  .preview {
    margin-top: 2rem;
    padding: 1rem;
    background: #f9f9f9;
    border-radius: 4px;
  }
  
  .preview pre {
    background: white;
    padding: 1rem;
    border-radius: 4px;
    overflow-x: auto;
    font-size: 0.875rem;
  }
</style>