AI先生のロボットキャラクター
第9章 - セクション9

フィルタータグを消すときに割れて飛び散るアニメーション

バツボタンを押すとフィルタータグがプラスチックが割れるように飛び散る美しいエフェクトをかけてます。効果音つき。

フィルタータグの削除は、多くのWebアプリケーションで頻繁に行われる操作ですが、ただ消えるだけでは味気なく、操作した感覚も薄れてしまいます。このパーツでは、バツボタンを押すとタグがプラスチックのように割れて飛び散る、印象的なエフェクトを実装します。

まずSVGでギザギザのヒビ割れを描画し、その後タグを複数の破片に分割して放射状に飛び散らせます。Web Audio APIでガラスが割れるような効果音を動的に生成し、聴覚的なフィードバックも加えることで、リアルな削除体験を提供します。

作成したフィルタータグを消すときに割れて飛び散るアニメーション

バツボタンを押すとフィルタータグがプラスチックが割れるように飛び散る美しいエフェクトをかけてます。効果音つき。

HTML
<div class="tag-container">
    <div class="tags-list" id="tagsList">
        <div class="tag" data-tag="JavaScript">
            <span>JavaScript</span>
            <button class="tag-close" aria-label="削除"></button>
        </div>
        <div class="tag" data-tag="CSS">
            <span>CSS</span>
            <button class="tag-close" aria-label="削除"></button>
        </div>
        <div class="tag" data-tag="HTML">
            <span>HTML</span>
            <button class="tag-close" aria-label="削除"></button>
        </div>
        <div class="tag" data-tag="React">
            <span>React</span>
            <button class="tag-close" aria-label="削除"></button>
        </div>
        <div class="tag" data-tag="Vue.js">
            <span>Vue.js</span>
            <button class="tag-close" aria-label="削除"></button>
        </div>
    </div>
</div>
CSS
* {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            background: #e5e7eb;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
            min-height: 100vh;
            height: 100vh;
            width: 100%;
        }

        /* メインコンテンツ */
        .tag-container {
          width: 100%;
          max-width: 600px;
        }


        /* タグリスト */
        .tags-list {
            display: flex;
            justify-content: flex-start;
            flex-wrap: wrap;
            gap: 12px;
            position: relative;
        }

        /* タグ本体 */
        .tag {
            position: relative;
            display: inline-flex;
            align-items: center;
            gap: 8px;
            padding: 8px 12px 8px 16px;
            background: #9ca3af;
            color: white;
            border-radius: 20px;
            font-size: 14px;
            font-weight: 500;
            box-shadow: 0 2px 8px rgba(156, 163, 175, 0.3);
            transition: all 0.2s ease;
            cursor: default;
            user-select: none;
            isolation: isolate;
        }

        .tag.removing {
            transition: all 0.15s ease;
        }
        
        .tag > span {
            position: relative;
            z-index: 1;
        }

        .tag:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(156, 163, 175, 0.4);
        }

        /* バツボタン */
        .tag-close {
            width: 24px;
            height: 24px;
            border-radius: 50%;
            background: rgba(255, 255, 255, 0.2);
            border: none;
            z-index: 1;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
            transition: all 0.2s ease;
            position: relative;
        }

        .tag-close:hover {
            background: rgba(255, 255, 255, 0.3);
            transform: scale(1.1);
        }

        .tag-close::before,
        .tag-close::after {
            content: '';
            position: absolute;
            width: 12px;
            height: 2px;
            background: white;
            border-radius: 1px;
        }

        .tag-close::before {
            transform: rotate(45deg);
        }

        .tag-close::after {
            transform: rotate(-45deg);
        }

        /* ヒビ割れエフェクト */
        .crack-overlay {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            border-radius: 20px;
            overflow: hidden;
            pointer-events: none;
            z-index: 10;
        }

        .crack-line {
            position: absolute;
            background: rgba(255, 255, 255, 0.9);
            box-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
            transform-origin: top center;
            opacity: 0;
            animation: crackAppear 0.15s ease-out forwards;
        }

        @keyframes crackAppear {
            0% {
                opacity: 0;
                transform: scaleY(0);
            }
            100% {
                opacity: 1;
                transform: scaleY(1);
            }
        }

        /* ガラス破片(大きな塊) */
        .shard {
            position: fixed;
            pointer-events: none;
            will-change: transform, opacity;
            z-index: 9999;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
        }
        
        /* タグの破片セグメント */
        .tag-segment {
            position: fixed;
            pointer-events: none;
            will-change: transform, opacity;
            z-index: 9999;
        }



        /* レスポンシブ */
        @media (max-width: 768px) {
            .tag-container {
                padding: 30px 20px;
            }

            header h1 {
                font-size: 28px;
            }

            header p {
                font-size: 14px;
            }
        }

        /* パフォーマンス最適化 */
        @media (prefers-reduced-motion: reduce) {
            *,
            *::before,
            *::after {
                animation-duration: 0.01ms !important;
                animation-iteration-count: 1 !important;
                transition-duration: 0.01ms !important;
            }
        }
JavaScript
// タグデータ(リセット用)
const initialTags = [
    'JavaScript', 'CSS', 'HTML', 'React', 
    'Vue.js'
];

// ヒビ割れを作成
function createCracks(tagElement) {
    const overlay = document.createElement('div');
    overlay.className = 'crack-overlay';
    
    const rect = tagElement.getBoundingClientRect();
    const centerX = rect.width / 2;
    const centerY = rect.height / 2;
    
    // ヒビの数(8-12本のランダムなヒビ)
    const crackCount = Math.floor(Math.random() * 3) + 3;
    
    for (let i = 0; i < crackCount; i++) {
        // ギザギザのヒビを作成
        createZigzagCrack(overlay, rect, centerX, centerY, i, crackCount);
    }
    
    tagElement.style.position = 'relative';
    tagElement.appendChild(overlay);
}

// ギザギザのヒビを作成
function createZigzagCrack(overlay, rect, centerX, centerY, index, total) {
    // SVGを使ってギザギザの線を描画
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.style.position = 'absolute';
    svg.style.top = '0';
    svg.style.left = '0';
    svg.style.width = '100%';
    svg.style.height = '100%';
    svg.style.pointerEvents = 'none';
    svg.style.opacity = '0';
    svg.style.animation = 'crackAppear 0.15s ease-out forwards';
    svg.style.animationDelay = (Math.random() * 0.05) + 's';
    
    const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    
    // ヒビの方向
    const angle = (Math.PI * 2 * index / total) + (Math.random() - 0.5) * 0.3;
    
    // ヒビの長さ
    const length = rect.width * 0.4 + Math.random() * rect.width * 0.3;
    
    // ギザギザのパスを生成
    let pathData = `M ${centerX} ${centerY}`;
    const segments = 8 + Math.floor(Math.random() * 5);
    
    for (let i = 1; i <= segments; i++) {
        const t = i / segments;
        const distance = length * t;
        
        // 基本の方向
        let x = centerX + Math.cos(angle) * distance;
        let y = centerY + Math.sin(angle) * distance;
        
        // ギザギザを追加(左右にランダムにずらす)
        const zigzagAmount = (Math.random() - 0.5) * 15;
        x += Math.cos(angle + Math.PI / 2) * zigzagAmount;
        y += Math.sin(angle + Math.PI / 2) * zigzagAmount;
        
        pathData += ` L ${x} ${y}`;
    }
    
    path.setAttribute('d', pathData);
    path.setAttribute('stroke', 'rgba(255, 255, 255, 0.95)');
    path.setAttribute('stroke-width', Math.random() * 2 + 1.5);
    path.setAttribute('fill', 'none');
    path.setAttribute('stroke-linecap', 'round');
    path.style.filter = 'drop-shadow(0 0 2px rgba(255, 255, 255, 0.6))';
    
    svg.appendChild(path);
    overlay.appendChild(svg);
}

// ガラス割れエフェクト
function shatterEffect(tagElement) {
    const rect = tagElement.getBoundingClientRect();
    const centerX = rect.left + rect.width / 2;
    const centerY = rect.top + rect.height / 2;
    
    // タグに削除中クラスを追加
    tagElement.classList.add('removing');
    
    // 音を鳴らす(オプション)
    playShatterSound();
    
    // 1. まずヒビ割れを表示
    createCracks(tagElement);
    
    // 2. 少し待ってからタグを大きな破片に分割して飛び散らせる
    setTimeout(() => {
        createTagSegments(tagElement, rect, centerX, centerY);
        
        // タグを非表示
        tagElement.style.opacity = '0';
        
        // アニメーション完了後にタグを削除
        setTimeout(() => {
            tagElement.remove();
        }, 100);
    }, 200); // ヒビ割れ表示後0.2秒待つ
}

// タグを大きな破片(セグメント)に分割
function createTagSegments(tagElement, rect, centerX, centerY) {
    // セグメント数(8-10個のやや細かい塊)
    const segmentCount = 8 + Math.floor(Math.random() * 3);
    
    // タグのスタイルをコピー
    const computedStyle = window.getComputedStyle(tagElement);
    
    for (let i = 0; i < segmentCount; i++) {
        const segment = document.createElement('div');
        segment.className = 'tag-segment';
        
        // タグの見た目を完全にコピー
        segment.style.width = rect.width + 'px';
        segment.style.height = rect.height + 'px';
        segment.style.background = computedStyle.background;
        segment.style.color = computedStyle.color;
        segment.style.borderRadius = computedStyle.borderRadius;
        segment.style.fontSize = computedStyle.fontSize;
        segment.style.fontWeight = computedStyle.fontWeight;
        segment.style.display = 'flex';
        segment.style.alignItems = 'center';
        segment.style.gap = '8px';
        segment.style.padding = '8px 12px 8px 16px';
        segment.textContent = tagElement.textContent;
        
        // 開始位置
        segment.style.left = rect.left + 'px';
        segment.style.top = rect.top + 'px';
        
        // 不規則な多角形でクリップ(ヒビ割れた破片のように)
        const clipPath = generateSegmentClipPath(i, segmentCount);
        segment.style.clipPath = clipPath;
        
        // 影を追加
        segment.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.5)';
        
        document.body.appendChild(segment);
        
        // アニメーション
        setTimeout(() => {
            // 飛び散る方向(中心から放射状)
            const angle = (Math.PI * 2 * i / segmentCount) + (Math.random() - 0.5) * 0.6;
            const distance = Math.random() * 280 + 220;
            const translateX = Math.cos(angle) * distance;
            const translateY = Math.sin(angle) * distance + Math.random() * 120;
            
            // 回転
            const rotation = (Math.random() - 0.5) * 900;
            
            segment.style.transition = `all ${0.7 + Math.random() * 0.3}s cubic-bezier(0.25, 0.46, 0.45, 0.94)`;
            segment.style.transform = `translate(${translateX}px, ${translateY}px) rotate(${rotation}deg) scale(0.5)`;
            segment.style.opacity = '0';
        }, 10);
        
        // 破片を削除
        setTimeout(() => {
            segment.remove();
        }, 1200);
    }
}

// セグメントのクリップパスを生成(不規則な形状)
function generateSegmentClipPath(index, total) {
    const centerX = 50;
    const centerY = 50;
    
    // セグメントの角度範囲
    const angleStart = (360 / total) * index;
    const angleEnd = (360 / total) * (index + 1);
    
    // 中心から外側へのポイントを作成
    const points = [`${centerX}% ${centerY}%`];
    
    // 不規則な形状を作る
    const steps = 4;
    for (let i = 0; i <= steps; i++) {
        const t = i / steps;
        const angle = angleStart + (angleEnd - angleStart) * t;
        const rad = (angle * Math.PI) / 180;
        
        // 距離をランダムに変化させる
        const distance = 70 + Math.random() * 30;
        const x = centerX + Math.cos(rad) * distance;
        const y = centerY + Math.sin(rad) * distance;
        
        points.push(`${x}% ${y}%`);
    }
    
    return `polygon(${points.join(', ')})`;
}

// 破片を生成
function createShard(rect, centerX, centerY) {
    const shard = document.createElement('div');
    shard.className = 'shard';
    
    // 破片のサイズ(ランダム)
    const size = Math.random() * 10 + 6;
    shard.style.width = size + 'px';
    shard.style.height = size + 'px';
    
    // 破片の形状(三角形や四角形)
    const shapes = [
        'polygon(50% 0%, 0% 100%, 100% 100%)', // 三角形
        'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)', // 四角形
        'polygon(30% 0%, 70% 0%, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0% 70%, 0% 30%)', // 八角形
        'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)' // ひし形
    ];
    const randomShape = shapes[Math.floor(Math.random() * shapes.length)];
    shard.style.clipPath = randomShape;
    
    // 破片の色(タグの色を継承 - より明確に)
    const colors = [
        '#667eea',
        '#764ba2',
        '#7c3aed',
        '#5b21b6'
    ];
    shard.style.background = colors[Math.floor(Math.random() * colors.length)];
    
    // 開始位置(タグ内のランダムな位置)
    const startX = rect.left + Math.random() * rect.width;
    const startY = rect.top + Math.random() * rect.height;
    shard.style.left = startX + 'px';
    shard.style.top = startY + 'px';
    
    // 初期状態を設定(重要!)
    shard.style.opacity = '1';
    shard.style.transform = 'translate(0, 0) rotate(0deg) scale(1)';
    
    // 飛び散る方向と距離
    const angle = Math.random() * Math.PI * 2;
    const distance = Math.random() * 300 + 150; // 飛び散る距離を増やす
    const translateX = Math.cos(angle) * distance;
    const translateY = Math.sin(angle) * distance + Math.random() * 150; // 重力効果
    
    // 回転(より多く回転)
    const rotation = Math.random() * 1080 - 540;
    
    document.body.appendChild(shard);
    
    // 少し待ってからアニメーション開始(レンダリングを確実にする)
    setTimeout(() => {
        shard.style.transition = `all ${0.8 + Math.random() * 0.4}s cubic-bezier(0.25, 0.46, 0.45, 0.94)`;
        shard.style.transform = `translate(${translateX}px, ${translateY}px) rotate(${rotation}deg) scale(0)`;
        shard.style.opacity = '0';
    }, 10);
    
    // 破片を削除
    setTimeout(() => {
        shard.remove();
    }, 1500);
}

// 音を鳴らす(Web Audio API使用)
function playShatterSound() {
    try {
        const audioContext = new (window.AudioContext || window.webkitAudioContext)();
        
        // ホワイトノイズを生成(短く鋭い音)
        const bufferSize = audioContext.sampleRate * 0.08;
        const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate);
        const output = buffer.getChannelData(0);
        
        for (let i = 0; i < bufferSize; i++) {
            output[i] = Math.random() * 2 - 1;
        }
        
        const whiteNoise = audioContext.createBufferSource();
        whiteNoise.buffer = buffer;
        
        // ハイパスフィルター(より高い周波数でガラスの音を再現)
        const highpassFilter = audioContext.createBiquadFilter();
        highpassFilter.type = 'highpass';
        highpassFilter.frequency.value = 4000; // 高音を強調
        highpassFilter.Q.value = 1.5;
        
        // バンドパスフィルター(ガラス特有の鋭い音域を強調)
        const bandpassFilter = audioContext.createBiquadFilter();
        bandpassFilter.type = 'bandpass';
        bandpassFilter.frequency.value = 6000; // より高い周波数
        bandpassFilter.Q.value = 2;
        
        // ゲイン(音量調整 - 鋭い減衰)
        const gainNode = audioContext.createGain();
        gainNode.gain.setValueAtTime(0.4, audioContext.currentTime);
        gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.08);
        
        // 接続(2つのフィルターを通して高音を強調)
        whiteNoise.connect(highpassFilter);
        highpassFilter.connect(bandpassFilter);
        bandpassFilter.connect(gainNode);
        gainNode.connect(audioContext.destination);
        
        // 再生
        whiteNoise.start(audioContext.currentTime);
        whiteNoise.stop(audioContext.currentTime + 0.08);
        
    } catch (e) {
        // 音が再生できない環境では無視
        console.log('Audio not supported');
    }
}

// タグのクローズボタンにイベントリスナーを追加
function attachTagEvents() {
    const tags = document.querySelectorAll('.tag');
    tags.forEach(tag => {
        const closeButton = tag.querySelector('.tag-close');
        if (closeButton && !closeButton.dataset.hasListener) {
            closeButton.dataset.hasListener = 'true';
            closeButton.addEventListener('click', (e) => {
                e.stopPropagation();
                shatterEffect(tag);
            });
        }
    });
}

// 初期化
document.addEventListener('DOMContentLoaded', () => {
    attachTagEvents();
});

// キーボードアクセシビリティ
document.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' || e.key === ' ') {
        const activeElement = document.activeElement;
        if (activeElement && activeElement.classList.contains('tag-close')) {
            e.preventDefault();
            activeElement.click();
        }
    }
});

このパーツの特徴

  • 2段階アニメーション 最初にヒビ割れを描画し、少し間を置いて破片が飛び散るリアルな演出
  • SVGでヒビ描画 ギザギザの不規則なヒビをSVGパスで精密に表現
  • clip-pathで破片生成 タグをpolygonで不規則な形状に分割し、リアルな破片を生成
  • Web Audio APIで効果音 ホワイトノイズとフィルターでガラスが割れる音を動的に生成
  • 放射状の飛び散り 中心から放射状に破片が飛び散り、回転と縮小で自然な動き

コードのポイント

SVGでギザギザのヒビ割れを描画

SVGパスを使って、中心から放射状に伸びるギザギザのヒビを生成します。

function createZigzagCrack(overlay, rect, centerX, centerY, index, total) {
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    
    const angle = (Math.PI * 2 * index / total) + (Math.random() - 0.5) * 0.3;
    const length = rect.width * 0.4 + Math.random() * rect.width * 0.3;
    
    let pathData = `M ${centerX} ${centerY}`;
    const segments = 8 + Math.floor(Math.random() * 5);
    
    for (let i = 1; i <= segments; i++) {
        const t = i / segments;
        const distance = length * t;
        
        let x = centerX + Math.cos(angle) * distance;
        let y = centerY + Math.sin(angle) * distance;
        
        const zigzagAmount = (Math.random() - 0.5) * 15;
        x += Math.cos(angle + Math.PI / 2) * zigzagAmount;
        y += Math.sin(angle + Math.PI / 2) * zigzagAmount;
        
        pathData += ` L ${x} ${y}`;
    }
    
    path.setAttribute('d', pathData);
    path.setAttribute('stroke', 'rgba(255, 255, 255, 0.95)');
    path.setAttribute('stroke-width', Math.random() * 2 + 1.5);
}

clip-pathで不規則な破片を生成

タグを複数の不規則な形状に分割し、それぞれに異なるclip-pathを適用します。

function generateSegmentClipPath(index, total) {
    const centerX = 50;
    const centerY = 50;
    
    const angleStart = (360 / total) * index;
    const angleEnd = (360 / total) * (index + 1);
    
    const points = [`${centerX}% ${centerY}%`];
    
    const steps = 4;
    for (let i = 0; i <= steps; i++) {
        const t = i / steps;
        const angle = angleStart + (angleEnd - angleStart) * t;
        const rad = (angle * Math.PI) / 180;
        
        const distance = 70 + Math.random() * 30;
        const x = centerX + Math.cos(rad) * distance;
        const y = centerY + Math.sin(rad) * distance;
        
        points.push(`${x}% ${y}%`);
    }
    
    return `polygon(${points.join(', ')})`;
}

放射状の飛び散りアニメーション

中心から放射状に破片を飛び散らせ、回転と縮小を加えます。

const angle = (Math.PI * 2 * i / segmentCount) + (Math.random() - 0.5) * 0.6;
const distance = Math.random() * 280 + 220;
const translateX = Math.cos(angle) * distance;
const translateY = Math.sin(angle) * distance + Math.random() * 120;

const rotation = (Math.random() - 0.5) * 900;

segment.style.transition = `all ${0.7 + Math.random() * 0.3}s cubic-bezier(0.25, 0.46, 0.45, 0.94)`;
segment.style.transform = `translate(${translateX}px, ${translateY}px) rotate(${rotation}deg) scale(0.5)`;
segment.style.opacity = '0';

Web Audio APIでガラスの音を生成

ホワイトノイズを生成し、ハイパスフィルターとバンドパスフィルターで高音を強調し、ガラスの割れる音を再現します。

function playShatterSound() {
    const audioContext = new (window.AudioContext || window.webkitAudioContext)();
    
    const bufferSize = audioContext.sampleRate * 0.08;
    const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate);
    const output = buffer.getChannelData(0);
    
    for (let i = 0; i < bufferSize; i++) {
        output[i] = Math.random() * 2 - 1;
    }
    
    const whiteNoise = audioContext.createBufferSource();
    whiteNoise.buffer = buffer;
    
    const highpassFilter = audioContext.createBiquadFilter();
    highpassFilter.type = 'highpass';
    highpassFilter.frequency.value = 4000;
    
    const gainNode = audioContext.createGain();
    gainNode.gain.setValueAtTime(0.4, audioContext.currentTime);
    gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.08);
    
    whiteNoise.connect(highpassFilter);
    highpassFilter.connect(gainNode);
    gainNode.connect(audioContext.destination);
    
    whiteNoise.start(audioContext.currentTime);
    whiteNoise.stop(audioContext.currentTime + 0.08);
}

まとめ

フィルタータグ割れ飛び散りのポイント
  • 段階的な演出 ヒビ割れ→破片飛散の順序でリアルな破壊を表現
  • SVGで精密な表現 ギザギザのヒビをSVGパスで丁寧に描画
  • clip-path活用 polygonで不規則な破片を生成し、リアルな割れ方を再現
  • Web Audio API フィルターを使ってガラス風の効果音を動的に生成
  • 自然な物理演算 放射状の移動、回転、縮小で破片の自然な動きを表現

単なフェードアウトとは異なり、物理的な破壊をアニメーションで表現することで、ユーザーに強い印象を与えます。効果音も加わることで、聴覚と視覚の両面から削除アクションを強調し、印象に残るUI体験を提供できます。

学習チェック

このレッスンを理解できたら「完了」をクリックしてください。
後で見直したい場合は「未完了に戻す」で進捗をリセットできます。

レッスン完了!🎉

お疲れさまでした!