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
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
canvasOptional 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
pwa--touch:startedDispatched 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 pointclientX(number): X coordinate relative to viewportclientY(number): Y coordinate relative to viewportpageX(number): X coordinate relative to documentpageY(number): Y coordinate relative to documentscreenX(number): X coordinate relative to screenscreenY(number): Y coordinate relative to screenradiusX(number): X radius of contact arearadiusY(number): Y radius of contact arearotationAngle(number): Rotation angle of contact areaforce(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
pwa--touch:movedDispatched 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
pwa--touch:endedDispatched 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
pwa--touch:cancelledDispatched 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
pwa--touch:updatedDispatched 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 identifierclientX(number): X coordinate relative to viewportclientY(number): Y coordinate relative to viewportpageX(number): X coordinate relative to documentpageY(number): Y coordinate relative to documentscreenX(number): X coordinate relative to screenscreenY(number): Y coordinate relative to screenradiusX(number): X radius of contact arearadiusY(number): Y radius of contact areaforce(number): Pressure of touchrotationAngle(number): Rotation angle of contact areatop(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
Prevent default scrolling: Use
touch-action: noneCSS on touch-interactive elementsHandle cancellation: Always handle
cancelledevents to clean up stateTrack touch identifiers: Use
identifierto track individual touches in multi-touch scenariosOptimize performance: Minimize work in touch event handlers, especially for
movedProvide visual feedback: Show immediate visual response to touch interactions
Consider touch size: Design touch targets at least 44x44 pixels
Test on devices: Touch behavior varies between devices - test on real hardware
Avoid hover-based UI: Touch devices don't have hover - design accordingly
Handle orientation changes: Account for device rotation
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:
Element doesn't have touch-action CSS property set
Event listeners on wrong element
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:
Throttle touch event processing
Use
requestAnimationFramefor renderingMinimize work in event handlers
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 });Only prevent default when necessary, as it disables scrolling and other default behaviors.
Last updated
Was this helpful?