Back to Notes
UX1 phút đọcNovember 26, 2025

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 visualViewport API, không dùng window.innerHeight cho sticky bottom elements
  • inputMode: numeric cho số thẻ/OTP, email cho email, tel cho 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 paddingBottom cho 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.