Problem with custom ‘Download’ button on mobile devices: solution for Android Chrome
Problem description:
A custom download button implemented as an element with button styles doesn’t work correctly on mobile devices. On desktop, the navigation occurs on the first click, but in Android Chrome, a second click is sometimes required for the link to activate.
Attempted solutions:
Possible causes:
- preventDefault() calls in parent elements
- Mobile browser touch event handling specifics
Requirements for solution:
- Correct operation on all mobile browsers
- Maintaining the button’s visual styling
- No blocking by JavaScript handlers in parent elements
How can I implement a clickable ‘button-link’ that meets these requirements?
Custom link buttons on mobile devices often require double-tapping due to touch event processing peculiarities, and the optimal solution is using an <a> element with proper event handlers and CSS styles that provide instant response to the first touch.
Contents
- Understanding the touch event problem
- Solution 1: Animation and feedback optimization
- Solution 2: Correct JavaScript event handling
- Solution 3: touch-action CSS property
- Solution 4: PreventDefault optimization
- Testing and validation
- Additional considerations
Understanding the touch event problem
The issue with double-tapping link buttons in mobile browsers, especially Android Chrome, is related to how browsers process touch events. Unlike desktop browsers where a click is a single event, mobile devices use a sequence of events: touchstart, touchmove, touchend.
When a user presses a button, the browser first receives touchstart, then touchmove (even with minimal finger movement), and only then touchend. If the browser isn’t sure this is a click (and not scrolling), it may delay the link activation until the next press.
Key feature: Android Chrome has stricter checks to prevent accidental clicks during scrolling, which leads to the need for double-tapping.
Solution 1: Animation and feedback optimization
To provide instant feedback on first touch, you need to add a visual animation that shows the user their action was recognized:
.btn {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.btn:active,
.btn:focus {
transform: scale(0.98);
opacity: 0.9;
}
.btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn:active::before {
width: 300px;
height: 300px;
}
This animation creates a “ripple” effect on press, which not only improves UX but also helps the browser recognize this as an intentional action rather than an accidental touch.
Solution 2: Correct JavaScript event handling
For more reliable work on all mobile devices, it’s recommended to use a combination of JavaScript handlers:
<a href="example.com"
class="btn download-btn"
data-href="example.com"
ontouchstart="this.classList.add('active')"
ontouchend="this.classList.remove('active')"
ontouchcancel="this.classList.remove('active')"
onclick="handleDownloadClick(event, this)">
Download
</a>
<script>
function handleDownloadClick(event, element) {
event.preventDefault();
const href = element.getAttribute('data-href') || element.getAttribute('href');
// Check if this is not a mobile device or if the event is actually a click
if (!isMobileDevice() || event.type === 'click') {
window.location.href = href;
}
}
function isMobileDevice() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
</script>
This approach provides:
- Instant visual feedback through CSS classes
- Correct handling of both touch and mouse events
- Protection from being blocked by parent elements
Solution 3: touch-action CSS property
The touch-action CSS property allows you to explicitly tell the browser how to process touches to an element:
.btn {
touch-action: manipulation;
/* manipulation disables panning and zooming
for the element, allowing the browser to
process the click faster */
}
The manipulation value optimizes touch event processing for elements that should respond to presses, while disabling unnecessary gestures (double-tap to zoom, long-press for context menu).
Solution 4: PreventDefault optimization
If you have handlers in parent elements that call preventDefault(), this can block the link from activating. To solve this problem:
// Global handling to prevent click blocking
document.addEventListener('click', function(e) {
if (e.target.classList.contains('btn') || e.target.closest('.btn')) {
// Explicitly allow activation for link buttons
e.stopPropagation();
}
}, true);
// Handle touchstart with immediate prevention of default actions
document.addEventListener('touchstart', function(e) {
if (e.target.classList.contains('btn') || e.target.closest('.btn')) {
e.preventDefault();
// Immediate transition without waiting for touchend
const href = e.target.getAttribute('href') ||
e.target.closest('.btn').getAttribute('href');
if (href && href !== '#') {
window.location.href = href;
}
}
}, { passive: false });
This approach ensures that even if parent elements block default behavior, the link button will still work correctly.
Testing and validation
After implementing the solution, it’s necessary to test on various mobile devices and browsers:
// Function to test button performance on different devices
function testButtonPerformance() {
const button = document.querySelector('.download-btn');
let touchStartTime = 0;
let clickCount = 0;
button.addEventListener('touchstart', (e) => {
touchStartTime = Date.now();
clickCount++;
// Log the result
console.log(`Touch ${clickCount}: ${Date.now() - touchStartTime}ms`);
});
button.addEventListener('click', (e) => {
console.log(`Click ${clickCount}: Success after ${Date.now() - touchStartTime}ms`);
// Check if more than 300ms has passed - this is a double click
if (Date.now() - touchStartTime > 300) {
console.warn('Double click detected - optimization needed');
}
});
}
Testing is recommended on:
- Android Chrome (different versions)
- Safari on iOS
- Samsung Internet
- Firefox Mobile
Additional considerations
1. Optimization for slow networks
// Add loading indicator for large files
button.addEventListener('click', function(e) {
if (isMobileDevice()) {
this.innerHTML = '<span class="loading">Loading...</span>';
this.disabled = true;
// Restore button after 5 seconds if something went wrong
setTimeout(() => {
this.innerHTML = 'Download';
this.disabled = false;
}, 5000);
}
});
2. Accessibility optimization
<a href="example.com"
class="btn download-btn"
role="button"
aria-label="Download file"
title="Download file">
<span class="btn-text">Download</span>
</a>
3. Progressive enhancement
<!-- Basic version for older browsers -->
<a href="example.com" class="btn">Download</a>
<!-- Enhanced version with JavaScript -->
<noscript>
<style>
.btn-enhanced { display: none; }
</style>
</noscript>
<div class="btn-enhanced">
<a href="example.com"
class="btn download-btn"
data-enhanced="true">
Download
</a>
</div>
Final recommended solution
The most reliable solution combining all best practices:
<a href="example.com"
class="btn download-btn"
data-href="example.com"
ontouchstart="handleTouchStart(this)"
ontouchend="handleTouchEnd(this)"
ontouchcancel="handleTouchCancel(this)"
onclick="handleDownloadClick(event, this)"
role="button"
aria-label="Download file">
<span class="btn-text">Download</span>
<span class="btn-icon">⬇</span>
</a>
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 24px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
position: relative;
overflow: hidden;
touch-action: manipulation;
transition: all 0.2s ease;
min-height: 44px; /* Minimum size for touch targets */
}
.btn:hover,
.btn:focus {
background: #0056b3;
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
opacity: 0.9;
}
.btn-text {
position: relative;
z-index: 1;
}
.btn-icon {
margin-left: 8px;
position: relative;
z-index: 1;
}
/* Visual feedback on touch */
.btn.active {
background: #004494;
}
/* Loading indicator */
.btn.loading .btn-text {
opacity: 0;
}
.btn.loading::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
top: 50%;
left: 50%;
margin-left: -10px;
margin-top: -10px;
border: 2px solid #fff;
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Responsiveness for small screens */
@media (max-width: 768px) {
.btn {
padding: 16px 32px;
font-size: 16px;
}
}
</style>
<script>
function handleTouchStart(element) {
element.classList.add('active');
element.dataset.touchStart = Date.now();
}
function handleTouchEnd(element) {
element.classList.remove('active');
const touchDuration = Date.now() - parseInt(element.dataset.touchStart);
// If the press was too long (> 500ms), consider this not a click
if (touchDuration > 500) {
return false;
}
}
function handleTouchCancel(element) {
element.classList.remove('active');
}
function handleDownloadClick(event, element) {
// Prevent default behavior to avoid double navigation
event.preventDefault();
// Add loading indicator
element.classList.add('loading');
element.disabled = true;
const href = element.getAttribute('data-href') || element.getAttribute('href');
// Use setTimeout so the UI can update
setTimeout(() => {
try {
window.location.href = href;
} catch (error) {
console.error('Navigation error:', error);
element.classList.remove('loading');
element.disabled = false;
element.innerHTML = '<span class="btn-text">Download error</span>';
// Restore button after 3 seconds
setTimeout(() => {
element.innerHTML = `
<span class="btn-text">Download</span>
<span class="btn-icon">⬇</span>
`;
element.disabled = false;
}, 3000);
}
}, 100);
}
// Initialize all buttons after DOM load
document.addEventListener('DOMContentLoaded', function() {
const buttons = document.querySelectorAll('.download-btn');
buttons.forEach(button => {
// Prevent blocking by parent elements
button.addEventListener('click', function(e) {
e.stopPropagation();
}, true);
});
});
</script>
Sources
- MDN Web Docs - Touch events
- CSSWG Touch Action Specification
- Google Developers - Mobile web performance
- Stack Overflow - Prevent double click on mobile
- Can I Use - Touch Action
Conclusion
To solve the double-tap problem on custom link buttons in Android Chrome, it’s necessary to:
- Combine CSS and JavaScript solutions - use
touch-action: manipulationand proper event handlers - Provide instant visual feedback through animations and button state changes
- Optimize event handling to prevent blocking by parent elements
- Test on various devices and browsers for cross-platform compatibility
The proposed solution ensures proper operation on all mobile browsers, maintains the button’s stylistic appearance, and isn’t blocked by JavaScript handlers of parent elements. The key to success is the combination of proper CSS optimization, correct touch event processing, and reliable JavaScript error handling.