Common Missing Content Descriptions in Dating Apps: Causes and Fixes
Content descriptions fail for three structural reasons in dating apps, and none of them are "we forgot."
What Causes Missing Content Descriptions in Dating Apps
Content descriptions fail for three structural reasons in dating apps, and none of them are "we forgot."
Dynamic content rendered without semantic context. Match cards, chat bubbles, and profile carousels are often built as custom View composites — ConstraintLayout nests containing ImageView, TextView, and clickable icons — but the parent container lacks android:contentDescription or accessibilityDelegate. TalkBack reads "ImageView, ImageView, TextView" instead of "Sarah, 28, 3 miles away, loves hiking."
Image loading libraries strip metadata. Glide, Coil, and Picasso load profile photos into ImageView targets. The contentDescription set in XML gets overwritten when the bitmap loads unless you explicitly preserve it. Most teams don't.
Cross-platform parity gaps. React Native's accessibilityLabel on works for static assets but fails on network images without explicit accessibilityProps. Flutter's Semantics widget requires manual wrapping. Dating apps shipping iOS/Android/Web from shared codebases consistently miss one platform.
State-dependent UI without state announcements. "Online now" badges, typing indicators, read receipts, and verification checkmarks change visibility based on WebSocket events. The content description stays static while the visual state flips. Screen reader users hear "verified badge" on an unverified profile because the description never updated.
Real-World Impact
App Store reviews tell the story. Search "TalkBack" or "VoiceOver" in Google Play reviews for Tinder, Bumble, Hinge. You'll find 1-star reviews like: "Can't read profiles, just hears 'button, button, image'" and "Matches disappear because I can't find the chat button." These aren't edge cases — they're blockers.
Revenue correlation. A 2023 analysis of 12 dating apps showed a 0.3-star rating increase correlated with 12% higher Day 7 retention for accessibility-compliant builds. Blind and low-vision users *pay for premium subscriptions* at comparable rates — when they can use the app. One major app lost an estimated $2.3M ARR from accessibility churn before fixing content descriptions.
Legal exposure. NFB v. Target established that inaccessible apps violate ADA. Dating apps face additional scrutiny: they're places of public accommodation *and* handle sensitive personal data. Settlement costs average $250K-$500K plus mandated remediation timelines.
7 Dating-App-Specific Manifestations
| # | Component | What Screen Reader Hears | What User Sees |
|---|---|---|---|
| 1 | Profile card in discovery stack | "ImageView, button, button" | Photo, name/age/location, like/pass buttons |
| 2 | Verification badge | "ImageView" (static) | Blue checkmark appears/disappears after video selfie verification |
| 3 | Chat list item | "TextView, TextView" | Contact name, last message preview, timestamp, unread count badge |
| 4 | Photo carousel in profile detail | "ImageView" (repeated 6x) | Swipeable gallery with caption overlay per photo |
| 5 | Super Like / Boost button | "Button" | Animated star/lightning icon with pulse effect |
| 6 | Distance filter pill | "Button, Button" | "5 mi", "10 mi", "25 mi" chips with active state |
| 7 | Report/Block menu item | "MenuItem" | "Report Sarah", "Block Sarah", "Unmatch" in bottom sheet |
Critical nuance: Example 2 (verification badge) is a *security* issue. Blind users cannot distinguish verified from unverified profiles — a documented vector for romance scams targeting visually impaired users.
Detection: Tools and Techniques
Automated (CI gate):
# .github/workflows/a11y.yml
- name: Run Accessibility Scanner
run: |
./gradlew assembleDebug
java -jar accessibility-scanner.jar \
--app app/build/outputs/apk/debug/app-debug.apk \
--test-type content-description \
--fail-on-violation
SUSATest autonomous exploration catches this differently: our accessibility persona navigates every screen, triggers every state (matched, chatting, blocked, reported), and logs missing contentDescription per element with screenshot + view hierarchy. It finds the *dynamic* gaps static analyzers miss — like the verification badge that only appears after WebSocket confirmation.
Manual testing checklist:
- Enable TalkBack → swipe through discovery stack → verify each card reads: "Name, Age, Distance, [Bio snippet], Like button, Pass button"
- Open profile detail → swipe photo carousel → verify each photo reads caption: "Photo 3 of 6: At Machu Picchu"
- Trigger verification flow → confirm badge announces "Verified profile" *after* completion
- Open chat list → verify unread count announces: "Sarah, 3 unread messages, last: 'Hey! 2m ago'"
- Long-press match → verify report menu reads: "Report Sarah for spam, Block Sarah, Unmatch"
What to look for in logs:
AccessibilityNodeInfo.getContentDescription() == nullon clickable elementsView.getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_NOon containers that *should* be focusableAccessibilityEvent.TYPE_VIEW_CLICKEDwithout precedingTYPE_VIEW_FOCUSED(focus order broken)
Fixes: Code-Level Guidance
1. Profile Card (Discovery Stack)
// ProfileCardView.kt
class ProfileCardView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val profilePhoto: ImageView = findViewById(R.id.profile_photo)
private val nameAgeLocation: TextView = findViewById(R.id.name_age_location)
private val bioSnippet: TextView = findViewById(R.id.bio_snippet)
private val likeBtn: MaterialButton = findViewById(R.id.like_btn)
private val passBtn: MaterialButton = findViewById(R.id.pass_btn)
fun bind(profile: Profile, onLike: () -> Unit, onPass: () -> Unit) {
// Photo: use alt text from profile or fallback
profilePhoto.contentDescription = profile.photos.firstOrNull()?.altText
?: "${profile.name}'s profile photo"
// Composite description for the whole card
val cardDesc = StringBuilder()
.append("${profile.name}, ${profile.age}")
.append(", ${profile.distanceKm} kilometers away")
profile.bio?.let { cardDesc.append(", ${it.take(100)}") }
contentDescription = cardDesc.toString()
// Buttons need explicit labels — "Like button" not "Button"
likeBtn.contentDescription = "Like ${profile.name}"
passBtn.contentDescription = "Pass on ${profile.name}"
// Ensure focus order: photo → name → bio → like → pass
ViewCompat.setAccessibilityTraversalBefore(likeBtn, R.id.bio_snippet)
ViewCompat.setAccessibilityTraversalBefore(passBtn, R.id.like_btn)
}
}
2. Verification Badge (State-Dependent)
// VerificationBadge.kt
class VerificationBadge @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
private var isVerified = false
fun setVerified(verified: Boolean) {
isVerified = verified
setImageResource(if (verified) R.drawable.ic_verified else 0)
visibility = if (verified) VISIBLE else GONE
// CRITICAL: Announce state change
contentDescription = if (verified) "Verified profile" else ""
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED)
}
override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
super.onInitializeAccessibilityNodeInfo(info)
info.isCheckable = true
info.isChecked = isVerified
info.className = "android.widget.CheckBox" // Semantic role
}
}
3. Chat List Item (Unread Count)
<!-- item_chat.xml -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/chat_container"
android:focusable="true"
android:clickable="true"
android:contentDescription="@string/chat_item_desc">
<TextView android:id="@+id/contact_name" ... />
<TextView android:id="@+id/last_message" ... />
<TextView android:id="@+id/timestamp" ... />
<TextView
android:id="@+id/unread_badge"
android:visibility="gone"
android:contentDescription="@string/unread_count" />
</androidx.constraintlayout.widget.ConstraintLayout>
// ChatAdapter.kt
override fun onBindViewHolder(holder: ChatViewHolder, position: Int) {
val chat = chats[position]
val container = holder.itemView.findViewById<View>(R.id.chat_container)
val desc = StringBuilder()
.append("${chat.contactName}")
if (chat.unreadCount > 0) {
desc.append(", ${chat.unreadCount} unread messages")
}
chat.lastMessage?.let { desc.append(", last message: $it") }
chat.timestamp?.let { desc.append(", ${formatTime(it)}") }
container.contentDescription = desc.toString()
// Update badge separately for screen readers
val badge = holder.itemView.findViewById<TextView>(R.id.unread_badge)
badge.apply {
text = chat.unreadCount.toString()
visibility = if (chat.unreadCount > 0) VISIBLE else GONE
contentDescription = if (chat.unreadCount > 0)
"${chat.unreadCount} unread messages" else ""
}
}
4. Photo Carousel (ViewPager2)
// ProfilePhotoAdapter.kt
class ProfilePhoto
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free