Touch

The Touch component provides access to touch events generated by touchscreens and other touch-sensitive input devices. It allows web applications to respond to user gestures such as taps, swipes, pinches, rotations, and multi-touch interactions, enabling rich mobile experiences.

This component is particularly useful for:

  • Drawing and painting applications

  • Gesture-based navigation and controls

  • Multi-touch games and interactive experiences

  • Image manipulation (pinch-to-zoom, rotation)

  • Custom touch-optimized UI components

  • Signature capture and handwriting recognition

  • Touch-based animations and effects

  • Canvas-based interactive applications

  • Mobile-first interactive dashboards

Browser Support

The Touch API is widely supported on all modern mobile browsers and touch-enabled devices. Desktop browsers typically support touch events when used with touch-capable hardware (touchscreen monitors, trackpads with multi-touch support).

Supported Platforms:

  • iOS Safari: Full support

  • Android Chrome: Full support

  • Mobile Firefox: Full support

  • Desktop browsers with touch hardware: Full support

The component automatically detects touch capability. On non-touch devices, the events won't be dispatched.

Usage

Basic Touch Drawing

<div {{ stimulus_controller('@pwa/touch') }}>
    <h2>Touch Drawing Pad</h2>
    <canvas {{ stimulus_target('@pwa/touch', 'canvas') }}
            id="drawing-canvas"
            width="600"
            height="400">
    </canvas>
    <button id="clear-btn">Clear</button>
</div>

<script>
    const canvas = document.getElementById('drawing-canvas');
    const ctx = canvas.getContext('2d');

    ctx.strokeStyle = '#3b82f6';
    ctx.lineWidth = 3;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    let isDrawing = false;
    let lastX = 0;
    let lastY = 0;

    document.addEventListener('pwa--touch:started', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        isDrawing = true;
        lastX = touch.clientX - rect.left;
        lastY = touch.clientY - rect.top;
    });

    document.addEventListener('pwa--touch:moved', (event) => {
        if (!isDrawing) return;

        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();
        const x = touch.clientX - rect.left;
        const y = touch.clientY - rect.top;

        ctx.beginPath();
        ctx.moveTo(lastX, lastY);
        ctx.lineTo(x, y);
        ctx.stroke();

        lastX = x;
        lastY = y;
    });

    document.addEventListener('pwa--touch:ended', () => {
        isDrawing = false;
    });

    document.getElementById('clear-btn').addEventListener('click', () => {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
    });
</script>

<style>
    canvas {
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        touch-action: none;
        display: block;
        margin-bottom: 15px;
    }

    button {
        padding: 10px 20px;
        background: #ef4444;
        color: white;
        border: none;
        border-radius: 6px;
        cursor: pointer;
    }
</style>

Swipe Gesture Detection

<div {{ stimulus_controller('@pwa/touch') }}>
    <div id="swipe-area">
        <h2>Swipe Here</h2>
        <p id="swipe-result">Swipe in any direction</p>
    </div>
</div>

<script>
    let touchStartX = 0;
    let touchStartY = 0;
    let touchStartTime = 0;

    const SWIPE_THRESHOLD = 50; // minimum distance in pixels
    const SWIPE_TIME_LIMIT = 500; // maximum time in milliseconds

    document.addEventListener('pwa--touch:started', (event) => {
        const touch = event.detail;
        touchStartX = touch.clientX;
        touchStartY = touch.clientY;
        touchStartTime = Date.now();
    });

    document.addEventListener('pwa--touch:ended', (event) => {
        const touch = event.detail;
        const deltaX = touch.clientX - touchStartX;
        const deltaY = touch.clientY - touchStartY;
        const deltaTime = Date.now() - touchStartTime;

        // Check if it's a swipe (fast movement over threshold)
        if (deltaTime > SWIPE_TIME_LIMIT) return;

        const absX = Math.abs(deltaX);
        const absY = Math.abs(deltaY);

        if (absX < SWIPE_THRESHOLD && absY < SWIPE_THRESHOLD) return;

        let direction;
        if (absX > absY) {
            // Horizontal swipe
            direction = deltaX > 0 ? 'Right' : 'Left';
        } else {
            // Vertical swipe
            direction = deltaY > 0 ? 'Down' : 'Up';
        }

        const result = document.getElementById('swipe-result');
        result.textContent = `Swiped ${direction}! (${Math.round(Math.max(absX, absY))}px in ${deltaTime}ms)`;
        result.style.color = '#10b981';

        setTimeout(() => {
            result.textContent = 'Swipe in any direction';
            result.style.color = '';
        }, 2000);
    });
</script>

<style>
    #swipe-area {
        width: 100%;
        max-width: 600px;
        height: 300px;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        border-radius: 12px;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        color: white;
        user-select: none;
        touch-action: none;
    }

    #swipe-result {
        font-size: 18px;
        font-weight: 500;
    }
</style>

Pinch-to-Zoom

<div {{ stimulus_controller('@pwa/touch') }}>
    <h2>Pinch to Zoom</h2>

    <div id="zoom-container">
        <img id="zoom-image"
             src="/images/photo.jpg"
             alt="Zoomable image">
    </div>

    <div id="zoom-info">
        <p>Zoom: <span id="zoom-level">100%</span></p>
    </div>
</div>

<script>
    const image = document.getElementById('zoom-image');
    const zoomLevel = document.getElementById('zoom-level');

    let currentScale = 1;
    let initialDistance = 0;
    let initialScale = 1;

    document.addEventListener('pwa--touch:updated', (event) => {
        const { touches } = event.detail;

        // Pinch gesture requires exactly 2 touches
        if (touches.length === 2) {
            const touch1 = touches[0];
            const touch2 = touches[1];

            // Calculate distance between two touch points
            const dx = touch2.clientX - touch1.clientX;
            const dy = touch2.clientY - touch1.clientY;
            const distance = Math.sqrt(dx * dx + dy * dy);

            if (initialDistance === 0) {
                // First frame of pinch
                initialDistance = distance;
                initialScale = currentScale;
            } else {
                // Calculate scale based on distance change
                const scale = (distance / initialDistance) * initialScale;

                // Limit scale between 0.5x and 5x
                currentScale = Math.max(0.5, Math.min(5, scale));

                // Apply scale
                image.style.transform = `scale(${currentScale})`;

                // Update UI
                zoomLevel.textContent = `${Math.round(currentScale * 100)}%`;
            }
        } else {
            // Reset when not pinching
            initialDistance = 0;
        }
    });
</script>

<style>
    #zoom-container {
        width: 100%;
        max-width: 600px;
        height: 400px;
        overflow: hidden;
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        display: flex;
        align-items: center;
        justify-content: center;
        background: #f9fafb;
        touch-action: none;
    }

    #zoom-image {
        max-width: 100%;
        max-height: 100%;
        transition: transform 0.1s ease-out;
        touch-action: none;
    }

    #zoom-info {
        margin-top: 15px;
        padding: 10px;
        background: #f3f4f6;
        border-radius: 6px;
        text-align: center;
    }

    #zoom-level {
        font-weight: bold;
        color: #3b82f6;
    }
</style>

Multi-Touch Drawing with Different Colors

<div {{ stimulus_controller('@pwa/touch') }}>
    <h2>Multi-Touch Paint</h2>
    <p>Use multiple fingers to draw with different colors!</p>

    <canvas id="multi-canvas" width="600" height="400"></canvas>

    <button id="clear-multi">Clear Canvas</button>
</div>

<script>
    const canvas = document.getElementById('multi-canvas');
    const ctx = canvas.getContext('2d');

    const colors = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6'];
    const activeTouches = new Map();

    ctx.lineWidth = 4;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    document.addEventListener('pwa--touch:started', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        // Assign a color based on touch identifier
        const colorIndex = touch.identifier % colors.length;
        const color = colors[colorIndex];

        activeTouches.set(touch.identifier, {
            color: color,
            lastX: touch.clientX - rect.left,
            lastY: touch.clientY - rect.top
        });
    });

    document.addEventListener('pwa--touch:moved', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        const touchData = activeTouches.get(touch.identifier);
        if (!touchData) return;

        const x = touch.clientX - rect.left;
        const y = touch.clientY - rect.top;

        ctx.strokeStyle = touchData.color;
        ctx.beginPath();
        ctx.moveTo(touchData.lastX, touchData.lastY);
        ctx.lineTo(x, y);
        ctx.stroke();

        touchData.lastX = x;
        touchData.lastY = y;
    });

    document.addEventListener('pwa--touch:ended', (event) => {
        const touch = event.detail;
        activeTouches.delete(touch.identifier);
    });

    document.addEventListener('pwa--touch:cancelled', (event) => {
        const touch = event.detail;
        activeTouches.delete(touch.identifier);
    });

    document.getElementById('clear-multi').addEventListener('click', () => {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        activeTouches.clear();
    });
</script>

<style>
    #multi-canvas {
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        display: block;
        margin: 15px 0;
        touch-action: none;
        background: white;
    }
</style>

Rotation Gesture

<div {{ stimulus_controller('@pwa/touch') }}>
    <h2>Two-Finger Rotation</h2>

    <div id="rotation-container">
        <div id="rotatable-box">
            <p>Rotate Me!</p>
        </div>
    </div>

    <div id="rotation-info">
        <p>Rotation: <span id="rotation-angle">0°</span></p>
    </div>
</div>

<script>
    const box = document.getElementById('rotatable-box');
    const angleDisplay = document.getElementById('rotation-angle');

    let currentRotation = 0;
    let initialAngle = 0;
    let startRotation = 0;

    document.addEventListener('pwa--touch:updated', (event) => {
        const { touches } = event.detail;

        if (touches.length === 2) {
            const touch1 = touches[0];
            const touch2 = touches[1];

            // Calculate angle between two touch points
            const dx = touch2.clientX - touch1.clientX;
            const dy = touch2.clientY - touch1.clientY;
            const angle = Math.atan2(dy, dx) * (180 / Math.PI);

            if (initialAngle === null) {
                // First frame
                initialAngle = angle;
                startRotation = currentRotation;
            } else {
                // Calculate rotation delta
                let deltaAngle = angle - initialAngle;

                // Normalize to -180 to 180
                if (deltaAngle > 180) deltaAngle -= 360;
                if (deltaAngle < -180) deltaAngle += 360;

                currentRotation = startRotation + deltaAngle;

                // Apply rotation
                box.style.transform = `rotate(${currentRotation}deg)`;

                // Update display
                angleDisplay.textContent = `${Math.round(currentRotation)}°`;
            }
        } else {
            initialAngle = null;
        }
    });
</script>

<style>
    #rotation-container {
        width: 100%;
        max-width: 600px;
        height: 400px;
        display: flex;
        align-items: center;
        justify-content: center;
        background: #f9fafb;
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        touch-action: none;
    }

    #rotatable-box {
        width: 150px;
        height: 150px;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        border-radius: 12px;
        display: flex;
        align-items: center;
        justify-content: center;
        color: white;
        font-weight: bold;
        font-size: 18px;
        transition: transform 0.1s ease-out;
        user-select: none;
    }

    #rotation-info {
        margin-top: 15px;
        padding: 10px;
        background: #f3f4f6;
        border-radius: 6px;
        text-align: center;
    }

    #rotation-angle {
        font-weight: bold;
        color: #8b5cf6;
    }
</style>

Touch-Based Signature Pad

<div {{ stimulus_controller('@pwa/touch') }}>
    <h2>Signature Pad</h2>

    <div class="signature-container">
        <canvas id="signature-canvas" width="600" height="200"></canvas>
    </div>

    <div class="signature-controls">
        <button id="clear-signature">Clear</button>
        <button id="save-signature">Save Signature</button>
    </div>

    <div id="signature-preview" style="display: none;">
        <h3>Saved Signature:</h3>
        <img id="signature-image" alt="Signature">
    </div>
</div>

<script>
    const canvas = document.getElementById('signature-canvas');
    const ctx = canvas.getContext('2d');
    const preview = document.getElementById('signature-preview');
    const previewImage = document.getElementById('signature-image');

    // Configure canvas for signature
    ctx.strokeStyle = '#1f2937';
    ctx.lineWidth = 2;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    let isDrawing = false;
    let lastX = 0;
    let lastY = 0;
    let hasContent = false;

    // Draw background
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    document.addEventListener('pwa--touch:started', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        isDrawing = true;
        lastX = touch.clientX - rect.left;
        lastY = touch.clientY - rect.top;
        hasContent = true;
    });

    document.addEventListener('pwa--touch:moved', (event) => {
        if (!isDrawing) return;

        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();
        const x = touch.clientX - rect.left;
        const y = touch.clientY - rect.top;

        // Smooth line drawing
        ctx.beginPath();
        ctx.moveTo(lastX, lastY);
        ctx.lineTo(x, y);
        ctx.stroke();

        lastX = x;
        lastY = y;
    });

    document.addEventListener('pwa--touch:ended', () => {
        isDrawing = false;
    });

    document.getElementById('clear-signature').addEventListener('click', () => {
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        preview.style.display = 'none';
        hasContent = false;
    });

    document.getElementById('save-signature').addEventListener('click', () => {
        if (!hasContent) {
            alert('Please sign first!');
            return;
        }

        // Convert canvas to image
        const dataURL = canvas.toDataURL('image/png');
        previewImage.src = dataURL;
        preview.style.display = 'block';

        // You could also send this to a server
        console.log('Signature saved:', dataURL);
    });
</script>

<style>
    .signature-container {
        border: 2px dashed #d1d5db;
        border-radius: 8px;
        background: white;
        padding: 10px;
        margin: 15px 0;
    }

    #signature-canvas {
        display: block;
        border: 1px solid #e5e7eb;
        border-radius: 4px;
        touch-action: none;
        cursor: crosshair;
    }

    .signature-controls {
        display: flex;
        gap: 10px;
        margin: 15px 0;
    }

    .signature-controls button {
        padding: 10px 20px;
        border: none;
        border-radius: 6px;
        cursor: pointer;
        font-weight: 500;
    }

    #clear-signature {
        background: #ef4444;
        color: white;
    }

    #save-signature {
        background: #10b981;
        color: white;
    }

    #signature-preview {
        margin-top: 20px;
        padding: 20px;
        background: #f9fafb;
        border-radius: 8px;
    }

    #signature-image {
        max-width: 100%;
        border: 1px solid #e5e7eb;
        border-radius: 4px;
    }
</style>

Touch-Based Game (Bubble Pop)

<div {{ stimulus_controller('@pwa/touch') }}>
    <div class="game-container">
        <h2>Bubble Pop Game</h2>

        <div class="game-stats">
            <div>Score: <span id="score">0</span></div>
            <div>Time: <span id="timer">30</span>s</div>
        </div>

        <canvas id="game-canvas" width="600" height="400"></canvas>

        <button id="start-game">Start Game</button>
    </div>
</div>

<script>
    const canvas = document.getElementById('game-canvas');
    const ctx = canvas.getContext('2d');
    const scoreEl = document.getElementById('score');
    const timerEl = document.getElementById('timer');
    const startBtn = document.getElementById('start-game');

    let bubbles = [];
    let score = 0;
    let timeLeft = 30;
    let gameActive = false;
    let gameInterval = null;
    let spawnInterval = null;

    class Bubble {
        constructor() {
            this.x = Math.random() * (canvas.width - 60) + 30;
            this.y = canvas.height + 30;
            this.radius = 20 + Math.random() * 20;
            this.speed = 1 + Math.random() * 2;
            this.color = `hsl(${Math.random() * 360}, 70%, 60%)`;
        }

        update() {
            this.y -= this.speed;
        }

        draw() {
            ctx.fillStyle = this.color;
            ctx.beginPath();
            ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
            ctx.fill();

            // Highlight
            ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
            ctx.beginPath();
            ctx.arc(this.x - this.radius / 3, this.y - this.radius / 3, this.radius / 3, 0, Math.PI * 2);
            ctx.fill();
        }

        isOffScreen() {
            return this.y + this.radius < 0;
        }

        containsPoint(x, y) {
            const dx = x - this.x;
            const dy = y - this.y;
            return dx * dx + dy * dy <= this.radius * this.radius;
        }
    }

    function startGame() {
        score = 0;
        timeLeft = 30;
        bubbles = [];
        gameActive = true;

        scoreEl.textContent = score;
        timerEl.textContent = timeLeft;
        startBtn.disabled = true;

        gameInterval = setInterval(() => {
            timeLeft--;
            timerEl.textContent = timeLeft;

            if (timeLeft <= 0) {
                endGame();
            }
        }, 1000);

        spawnInterval = setInterval(() => {
            bubbles.push(new Bubble());
        }, 800);

        gameLoop();
    }

    function endGame() {
        gameActive = false;
        clearInterval(gameInterval);
        clearInterval(spawnInterval);
        startBtn.disabled = false;

        ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        ctx.fillStyle = 'white';
        ctx.font = '48px Arial';
        ctx.textAlign = 'center';
        ctx.fillText(`Game Over!`, canvas.width / 2, canvas.height / 2 - 30);
        ctx.font = '32px Arial';
        ctx.fillText(`Score: ${score}`, canvas.width / 2, canvas.height / 2 + 20);
    }

    function gameLoop() {
        if (!gameActive) return;

        // Clear canvas
        ctx.fillStyle = '#f0f9ff';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        // Update and draw bubbles
        bubbles = bubbles.filter(bubble => {
            bubble.update();
            bubble.draw();
            return !bubble.isOffScreen();
        });

        requestAnimationFrame(gameLoop);
    }

    document.addEventListener('pwa--touch:started', (event) => {
        if (!gameActive) return;

        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();
        const x = touch.clientX - rect.left;
        const y = touch.clientY - rect.top;

        // Check if touch hit any bubble
        for (let i = bubbles.length - 1; i >= 0; i--) {
            if (bubbles[i].containsPoint(x, y)) {
                // Pop the bubble
                bubbles.splice(i, 1);
                score += 10;
                scoreEl.textContent = score;

                // Visual feedback
                ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
                ctx.beginPath();
                ctx.arc(x, y, 30, 0, Math.PI * 2);
                ctx.fill();

                break;
            }
        }
    });

    startBtn.addEventListener('click', startGame);

    // Draw initial state
    ctx.fillStyle = '#f0f9ff';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = '#6b7280';
    ctx.font = '24px Arial';
    ctx.textAlign = 'center';
    ctx.fillText('Press Start to Play!', canvas.width / 2, canvas.height / 2);
</script>

<style>
    .game-container {
        max-width: 650px;
        margin: 0 auto;
    }

    .game-stats {
        display: flex;
        justify-content: space-around;
        margin: 15px 0;
        padding: 15px;
        background: #f3f4f6;
        border-radius: 8px;
        font-size: 18px;
        font-weight: bold;
    }

    #game-canvas {
        display: block;
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        touch-action: none;
        margin: 15px 0;
        background: #f0f9ff;
    }

    #start-game {
        width: 100%;
        padding: 15px;
        background: #3b82f6;
        color: white;
        border: none;
        border-radius: 6px;
        font-size: 18px;
        font-weight: bold;
        cursor: pointer;
    }

    #start-game:hover:not(:disabled) {
        background: #2563eb;
    }

    #start-game:disabled {
        opacity: 0.5;
        cursor: not-allowed;
    }
</style>

Parameters

None

Actions

None - This component automatically monitors touch events on the controller element.

Targets

canvas

Optional target for canvas elements. While not required, using this target ensures proper touch event handling on canvas elements.

<canvas {{ stimulus_target('@pwa/touch', 'canvas') }}>
</canvas>

Events

pwa--touch:started

Dispatched when a new touch contact is detected (finger touches the screen). Can fire multiple times if multiple fingers touch simultaneously.

Payload: Native Touch object

Properties include:

  • identifier (number): Unique identifier for this touch point

  • clientX (number): X coordinate relative to viewport

  • clientY (number): Y coordinate relative to viewport

  • pageX (number): X coordinate relative to document

  • pageY (number): Y coordinate relative to document

  • screenX (number): X coordinate relative to screen

  • screenY (number): Y coordinate relative to screen

  • radiusX (number): X radius of contact area

  • radiusY (number): Y radius of contact area

  • rotationAngle (number): Rotation angle of contact area

  • force (number): Pressure of touch (0-1, if supported)

Example:

document.addEventListener('pwa--touch:started', (event) => {
    const touch = event.detail;
    console.log(`Touch started at (${touch.clientX}, ${touch.clientY})`);
    console.log('Touch ID:', touch.identifier);
    console.log('Pressure:', touch.force);
});

pwa--touch:moved

Dispatched when a touch contact moves across the screen.

Payload: Native Touch object (same properties as started)

Example:

document.addEventListener('pwa--touch:moved', (event) => {
    const touch = event.detail;
    console.log(`Touch moved to (${touch.clientX}, ${touch.clientY})`);
});

pwa--touch:ended

Dispatched when a touch contact ends (finger lifts from screen).

Payload: Native Touch object (same properties as started)

Example:

document.addEventListener('pwa--touch:ended', (event) => {
    const touch = event.detail;
    console.log(`Touch ended at (${touch.clientX}, ${touch.clientY})`);
});

pwa--touch:cancelled

Dispatched when a touch contact is canceled by the browser or OS. This can happen due to:

  • Context switches (app switching, notifications)

  • Gesture interruptions (system gestures)

  • Too many simultaneous touches

  • Touch moved outside tracking area

Payload: Native Touch object (same properties as started)

Example:

document.addEventListener('pwa--touch:cancelled', (event) => {
    const touch = event.detail;
    console.log('Touch cancelled:', touch.identifier);
    // Clean up any ongoing operations for this touch
});

pwa--touch:updated

Dispatched after each native touch event, once per DOM event cycle. Provides a complete snapshot of all active touches managed by the controller. This is particularly useful for multi-touch gestures like pinch-zoom and rotation that require tracking multiple touch points simultaneously.

Payload: {touches: Array<TouchData>}

Each touch in the array contains:

  • identifier (number): Unique touch identifier

  • clientX (number): X coordinate relative to viewport

  • clientY (number): Y coordinate relative to viewport

  • pageX (number): X coordinate relative to document

  • pageY (number): Y coordinate relative to document

  • screenX (number): X coordinate relative to screen

  • screenY (number): Y coordinate relative to screen

  • radiusX (number): X radius of contact area

  • radiusY (number): Y radius of contact area

  • force (number): Pressure of touch

  • rotationAngle (number): Rotation angle of contact area

  • top (number): Y coordinate (alias for clientY)

  • left (number): X coordinate (alias for clientX)

Example:

document.addEventListener('pwa--touch:updated', (event) => {
    const { touches } = event.detail;

    console.log(`Active touches: ${touches.length}`);

    if (touches.length === 2) {
        // Pinch gesture - calculate distance
        const dx = touches[1].clientX - touches[0].clientX;
        const dy = touches[1].clientY - touches[0].clientY;
        const distance = Math.sqrt(dx * dx + dy * dy);
        console.log('Pinch distance:', distance);
    }
});

Best Practices

  1. Prevent default scrolling: Use touch-action: none CSS on touch-interactive elements

  2. Handle cancellation: Always handle cancelled events to clean up state

  3. Track touch identifiers: Use identifier to track individual touches in multi-touch scenarios

  4. Optimize performance: Minimize work in touch event handlers, especially for moved

  5. Provide visual feedback: Show immediate visual response to touch interactions

  6. Consider touch size: Design touch targets at least 44x44 pixels

  7. Test on devices: Touch behavior varies between devices - test on real hardware

  8. Avoid hover-based UI: Touch devices don't have hover - design accordingly

  9. Handle orientation changes: Account for device rotation

  10. Be mindful of gestures: Some gestures may conflict with system gestures

Gesture Detection Patterns

Tap Detection

let tapStartTime = 0;
let tapStartX = 0;
let tapStartY = 0;

const TAP_TIME_LIMIT = 200; // ms
const TAP_MOVE_THRESHOLD = 10; // px

document.addEventListener('pwa--touch:started', (event) => {
    tapStartTime = Date.now();
    tapStartX = event.detail.clientX;
    tapStartY = event.detail.clientY;
});

document.addEventListener('pwa--touch:ended', (event) => {
    const duration = Date.now() - tapStartTime;
    const dx = event.detail.clientX - tapStartX;
    const dy = event.detail.clientY - tapStartY;
    const distance = Math.sqrt(dx * dx + dy * dy);

    if (duration < TAP_TIME_LIMIT && distance < TAP_MOVE_THRESHOLD) {
        console.log('Tap detected!');
    }
});

Long Press Detection

let longPressTimer = null;
const LONG_PRESS_DURATION = 500; // ms

document.addEventListener('pwa--touch:started', (event) => {
    const touch = event.detail;

    longPressTimer = setTimeout(() => {
        console.log('Long press detected!');
        // Trigger long press action
    }, LONG_PRESS_DURATION);
});

document.addEventListener('pwa--touch:moved', () => {
    clearTimeout(longPressTimer);
});

document.addEventListener('pwa--touch:ended', () => {
    clearTimeout(longPressTimer);
});

Double Tap Detection

let lastTapTime = 0;
const DOUBLE_TAP_DELAY = 300; // ms

document.addEventListener('pwa--touch:ended', (event) => {
    const now = Date.now();

    if (now - lastTapTime < DOUBLE_TAP_DELAY) {
        console.log('Double tap detected!');
        lastTapTime = 0; // Reset
    } else {
        lastTapTime = now;
    }
});

Velocity Calculation

let lastTime = 0;
let lastX = 0;
let lastY = 0;

document.addEventListener('pwa--touch:moved', (event) => {
    const touch = event.detail;
    const now = Date.now();

    if (lastTime > 0) {
        const dt = (now - lastTime) / 1000; // seconds
        const dx = touch.clientX - lastX;
        const dy = touch.clientY - lastY;

        const velocityX = dx / dt; // px/s
        const velocityY = dy / dt; // px/s

        console.log(`Velocity: ${Math.round(velocityX)}, ${Math.round(velocityY)} px/s`);
    }

    lastTime = now;
    lastX = touch.clientX;
    lastY = touch.clientY;
});

Complete Example: Advanced Drawing App

<div {{ stimulus_controller('@pwa/touch') }}>
    <div class="drawing-app">
        <header>
            <h1>Touch Drawing Studio</h1>
        </header>

        <div class="toolbar">
            <div class="tool-group">
                <label>Brush Size:</label>
                <input type="range" id="brush-size" min="1" max="50" value="3">
                <span id="brush-size-value">3px</span>
            </div>

            <div class="tool-group">
                <label>Color:</label>
                <input type="color" id="brush-color" value="#3b82f6">
            </div>

            <div class="tool-group">
                <label>Opacity:</label>
                <input type="range" id="brush-opacity" min="0" max="100" value="100">
                <span id="opacity-value">100%</span>
            </div>

            <div class="tool-group">
                <button id="eraser-btn">Eraser</button>
                <button id="pen-btn" class="active">Pen</button>
            </div>

            <div class="tool-group">
                <button id="undo-btn">Undo</button>
                <button id="clear-all-btn">Clear All</button>
                <button id="save-drawing-btn">Save</button>
            </div>
        </div>

        <canvas id="drawing-canvas-advanced" width="800" height="600"></canvas>

        <div class="touch-indicators" id="touch-indicators"></div>
    </div>
</div>

<script>
    const canvas = document.getElementById('drawing-canvas-advanced');
    const ctx = canvas.getContext('2d');
    const indicators = document.getElementById('touch-indicators');

    // Tools
    const brushSizeInput = document.getElementById('brush-size');
    const brushSizeValue = document.getElementById('brush-size-value');
    const brushColorInput = document.getElementById('brush-color');
    const brushOpacityInput = document.getElementById('brush-opacity');
    const opacityValue = document.getElementById('opacity-value');
    const eraserBtn = document.getElementById('eraser-btn');
    const penBtn = document.getElementById('pen-btn');
    const undoBtn = document.getElementById('undo-btn');
    const clearBtn = document.getElementById('clear-all-btn');
    const saveBtn = document.getElementById('save-drawing-btn');

    // State
    let activeTouches = new Map();
    let brushSize = 3;
    let brushColor = '#3b82f6';
    let brushOpacity = 1;
    let isEraser = false;
    let history = [];
    const MAX_HISTORY = 20;

    // Initialize canvas
    ctx.fillStyle = 'white';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    saveState();

    // Tool handlers
    brushSizeInput.addEventListener('input', (e) => {
        brushSize = parseInt(e.target.value);
        brushSizeValue.textContent = `${brushSize}px`;
    });

    brushColorInput.addEventListener('input', (e) => {
        brushColor = e.target.value;
        if (isEraser) {
            penBtn.click();
        }
    });

    brushOpacityInput.addEventListener('input', (e) => {
        brushOpacity = parseInt(e.target.value) / 100;
        opacityValue.textContent = `${e.target.value}%`;
    });

    eraserBtn.addEventListener('click', () => {
        isEraser = true;
        eraserBtn.classList.add('active');
        penBtn.classList.remove('active');
    });

    penBtn.addEventListener('click', () => {
        isEraser = false;
        penBtn.classList.add('active');
        eraserBtn.classList.remove('active');
    });

    undoBtn.addEventListener('click', () => {
        if (history.length > 1) {
            history.pop();
            const previousState = history[history.length - 1];
            const img = new Image();
            img.onload = () => {
                ctx.clearRect(0, 0, canvas.width, canvas.height);
                ctx.drawImage(img, 0, 0);
            };
            img.src = previousState;
        }
    });

    clearBtn.addEventListener('click', () => {
        if (confirm('Clear the entire canvas?')) {
            ctx.fillStyle = 'white';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            saveState();
        }
    });

    saveBtn.addEventListener('click', () => {
        const dataURL = canvas.toDataURL('image/png');
        const link = document.createElement('a');
        link.download = `drawing-${Date.now()}.png`;
        link.href = dataURL;
        link.click();
    });

    function saveState() {
        history.push(canvas.toDataURL());
        if (history.length > MAX_HISTORY) {
            history.shift();
        }
    }

    // Touch drawing
    document.addEventListener('pwa--touch:started', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        activeTouches.set(touch.identifier, {
            lastX: touch.clientX - rect.left,
            lastY: touch.clientY - rect.top
        });

        updateTouchIndicators();
    });

    document.addEventListener('pwa--touch:moved', (event) => {
        const touch = event.detail;
        const rect = canvas.getBoundingClientRect();

        const touchData = activeTouches.get(touch.identifier);
        if (!touchData) return;

        const x = touch.clientX - rect.left;
        const y = touch.clientY - rect.top;

        // Draw
        ctx.globalAlpha = isEraser ? 1 : brushOpacity;
        ctx.strokeStyle = isEraser ? 'white' : brushColor;
        ctx.lineWidth = isEraser ? brushSize * 3 : brushSize;

        ctx.beginPath();
        ctx.moveTo(touchData.lastX, touchData.lastY);
        ctx.lineTo(x, y);
        ctx.stroke();

        ctx.globalAlpha = 1;

        touchData.lastX = x;
        touchData.lastY = y;
    });

    document.addEventListener('pwa--touch:ended', (event) => {
        const touch = event.detail;
        activeTouches.delete(touch.identifier);

        if (activeTouches.size === 0) {
            saveState();
        }

        updateTouchIndicators();
    });

    document.addEventListener('pwa--touch:cancelled', (event) => {
        const touch = event.detail;
        activeTouches.delete(touch.identifier);
        updateTouchIndicators();
    });

    function updateTouchIndicators() {
        indicators.textContent = `Active touches: ${activeTouches.size}`;
    }
</script>

<style>
    .drawing-app {
        max-width: 900px;
        margin: 0 auto;
        padding: 20px;
    }

    header {
        text-align: center;
        margin-bottom: 20px;
    }

    .toolbar {
        display: flex;
        flex-wrap: wrap;
        gap: 20px;
        padding: 15px;
        background: #f9fafb;
        border-radius: 8px;
        margin-bottom: 20px;
    }

    .tool-group {
        display: flex;
        align-items: center;
        gap: 10px;
    }

    .tool-group label {
        font-weight: 500;
        font-size: 14px;
    }

    .tool-group input[type="range"] {
        width: 100px;
    }

    .tool-group input[type="color"] {
        width: 50px;
        height: 35px;
        border: 1px solid #e5e7eb;
        border-radius: 4px;
        cursor: pointer;
    }

    .tool-group button {
        padding: 8px 16px;
        border: 2px solid #e5e7eb;
        background: white;
        border-radius: 6px;
        cursor: pointer;
        font-weight: 500;
        transition: all 0.2s;
    }

    .tool-group button:hover {
        background: #f3f4f6;
    }

    .tool-group button.active {
        background: #3b82f6;
        color: white;
        border-color: #3b82f6;
    }

    #drawing-canvas-advanced {
        display: block;
        border: 2px solid #e5e7eb;
        border-radius: 8px;
        touch-action: none;
        cursor: crosshair;
        background: white;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    }

    .touch-indicators {
        margin-top: 15px;
        padding: 10px;
        background: #f3f4f6;
        border-radius: 6px;
        text-align: center;
        font-weight: 500;
    }

    @media (max-width: 768px) {
        .toolbar {
            flex-direction: column;
        }

        #drawing-canvas-advanced {
            width: 100%;
            height: auto;
        }
    }
</style>

Troubleshooting

Touch events not firing

Possible causes:

  1. Element doesn't have touch-action CSS property set

  2. Event listeners on wrong element

  3. Browser doesn't support touch events

Solutions:

.touch-element {
    touch-action: none; /* Prevent default touch behaviors */
}

Accidental scrolling during drawing

Issue: Page scrolls when trying to draw

Solution: Use touch-action: none on interactive elements:

canvas {
    touch-action: none;
}

Multi-touch not working

Issue: Only one touch point detected

Cause: Using started/moved/ended events instead of updated

Solution: Use the updated event for multi-touch gestures:

document.addEventListener('pwa--touch:updated', (event) => {
    const { touches } = event.detail;
    if (touches.length === 2) {
        // Handle two-finger gesture
    }
});

Touch coordinates incorrect

Issue: Touch position doesn't match visual location

Cause: Not accounting for element offset

Solution: Calculate relative coordinates:

const rect = element.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;

Performance issues with many touch events

Issue: App lags during touch interaction

Solutions:

  1. Throttle touch event processing

  2. Use requestAnimationFrame for rendering

  3. Minimize work in event handlers

  4. Use Web Workers for heavy computations

let rafId = null;

document.addEventListener('pwa--touch:moved', (event) => {
    if (rafId) return;

    rafId = requestAnimationFrame(() => {
        // Process touch event
        rafId = null;
    });
});

Ghost clicks on touch devices

Issue: Touch triggers both touch and click events

Solution: Prevent default on touch events when needed:

document.addEventListener('touchstart', (e) => {
    e.preventDefault(); // Prevents ghost click
}, { passive: false });

Last updated

Was this helpful?