Mobile Checkout UX — Tại sao conversion rate trên mobile thấp hơn desktop
Người dùng mobile chiếm 60-70% traffic nhưng conversion rate chỉ bằng một nửa desktop. Lý do không phải vì màn hình nhỏ — mà vì những vấn đề UX kỹ thuật mà dev thường không test kỹ trên thiết bị thật.
Mobile Checkout UX — Tại sao conversion rate trên mobile thấp hơn desktop
Trên mobile, user gặp những vấn đề mà desktop không có: virtual keyboard che khuất form, nút bấm quá nhỏ, keyboard sai loại cho từng input, và payment flow phức tạp hơn. Nhiều vấn đề trong số này chỉ xảy ra trên thiết bị thật, không reproduce trên Chrome DevTools.
Vấn đề 1: Virtual Keyboard Che Layout
Khi keyboard bật, window.innerHeight trên iOS không thay đổi — nhưng visualViewport.height thay đổi. Nhiều developer dùng window.innerHeight để tính toán vị trí sticky element, dẫn đến CTA bị che.
tsx
// hooks/useVisualViewport.ts
export function useVisualViewport() {
const [viewportHeight, setViewportHeight] = useState(
typeof window !== "undefined" ? window.visualViewport?.height ?? window.innerHeight : 0
);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const handler = () => setViewportHeight(vv.height);
vv.addEventListener("resize", handler);
vv.addEventListener("scroll", handler);
return () => {
vv.removeEventListener("resize", handler);
vv.removeEventListener("scroll", handler);
};
}, []);
return viewportHeight;
}
// Sticky CTA component
function StickyCheckoutButton() {
const viewportHeight = useVisualViewport();
const windowHeight = typeof window !== "undefined" ? window.innerHeight : 0;
// Khoảng cách từ bottom của visual viewport đến bottom của window
const offsetFromBottom = windowHeight - viewportHeight;
return (
<div
className="fixed left-0 right-0 bg-white border-t px-4 py-3 z-50"
style={{ bottom: offsetFromBottom }}
>
<button className="w-full bg-blue-600 text-white rounded-lg py-3 text-lg font-semibold">
Thanh toán
</button>
</div>
);
}Vấn đề 2: inputmode Attribute — Keyboard Đúng Loại
tsx
function CheckoutForm() {
return (
<form>
{/* Số thẻ tín dụng — numeric keyboard, không phải text */}
<input
type="text" // Không dùng type="number" cho số thẻ — mất leading zero
inputMode="numeric"
pattern="[0-9]*"
autoComplete="cc-number"
placeholder="1234 5678 9012 3456"
/>
{/* CVV */}
<input
type="text"
inputMode="numeric"
pattern="[0-9]{3,4}"
autoComplete="cc-csc"
maxLength={4}
placeholder="CVV"
/>
{/* Email */}
<input
type="email"
inputMode="email" // Keyboard có @ và .com key
autoComplete="email"
placeholder="[email protected]"
/>
{/* Số điện thoại */}
<input
type="tel"
inputMode="tel" // Keyboard dạng dialpad
autoComplete="tel"
placeholder="0912 345 678"
/>
{/* OTP */}
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code" // iOS tự suggest OTP từ SMS
pattern="[0-9]{6}"
maxLength={6}
placeholder="Mã OTP"
/>
</form>
);
}Vấn đề 3: Tap Target Size
tsx
// ❌ Sai: button quá nhỏ
<button className="p-1 text-xs">Xóa</button>
// ✅ Đúng: tap target tối thiểu 44×44px
// Dùng padding để tăng area mà không thay đổi visual size
<button
className="relative p-2"
style={{ minWidth: 44, minHeight: 44 }}
>
<span className="text-xs">Xóa</span>
</button>
// Hoặc dùng CSS before/after pseudo-element để tăng tap area
// mà không ảnh hưởng layout:
.small-button::after {
content: '';
position: absolute;
inset: -10px; /* Mở rộng tap area ra ngoài 10px mỗi phía */
}Vấn đề 4: Detect Payment Method Availability
tsx
// hooks/useAvailablePaymentMethods.ts
export function useAvailablePaymentMethods() {
const [available, setAvailable] = useState({
applePay: false,
googlePay: false,
});
useEffect(() => {
// Apple Pay
if (typeof window !== "undefined" && "ApplePaySession" in window) {
const isAvailable = ApplePaySession.canMakePayments();
setAvailable((prev) => ({ ...prev, applePay: isAvailable }));
}
// Google Pay
const checkGooglePay = async () => {
if (typeof window === "undefined") return;
try {
const paymentsClient = new google.payments.api.PaymentsClient({
environment: "PRODUCTION",
});
const { result } = await paymentsClient.isReadyToPay({
apiVersion: 2,
apiVersionMinor: 0,
allowedPaymentMethods: [CARD_PAYMENT_METHOD],
});
setAvailable((prev) => ({ ...prev, googlePay: result }));
} catch {
// Google Pay không available
}
};
checkGooglePay();
}, []);
return available;
}
// Usage
function PaymentOptions() {
const { applePay, googlePay } = useAvailablePaymentMethods();
return (
<div className="space-y-3">
{applePay && (
<button className="w-full bg-black text-white rounded-lg py-3 flex items-center justify-center gap-2">
<ApplePayIcon /> Thanh toán với Apple Pay
</button>
)}
{googlePay && (
<GooglePayButton />
)}
<button className="w-full border rounded-lg py-3">
Thẻ tín dụng / Ghi nợ
</button>
</div>
);
}Vấn đề 5: Sticky CTA — Không Overlap Nội Dung
tsx
// Thêm padding-bottom cho trang để nội dung không bị sticky CTA che
function CheckoutPage() {
const STICKY_BAR_HEIGHT = 76; // chiều cao của sticky CTA bar
return (
<div
className="pb-20" // padding-bottom = sticky bar height + buffer
style={{ paddingBottom: STICKY_BAR_HEIGHT + 16 }}
>
<CheckoutForm />
<OrderSummary />
</div>
<StickyCheckoutButton />
);
}Key Takeaways
- Virtual keyboard: dùng
visualViewportAPI, không dùngwindow.innerHeightcho sticky bottom elements - inputMode:
numericcho số thẻ/OTP,emailcho email,telcho phone - Tap target: tối thiểu 44×44px — dùng pseudo-element nếu không muốn thay đổi visual
- Payment detection: check Apple Pay / Google Pay availability runtime, hiện tùy theo device
- Sticky CTA: add
paddingBottomcho page content bằng chiều cao của sticky bar
If you found this helpful, leave a like!
Related Posts
Comments available after DB is connected.