Accessibility and motion
kara’s components are read by VoiceOver and TalkBack the way the platform’s own controls are, and their motion follows the user’s Reduce Motion setting. For what a component cannot know (what an icon means, where a row leads), one set of props says it on both platforms: SwiftUI’s accessibility modifiers on iOS, Compose semantics on Android.
The props
The stacks, Text, Card, Surface, ListItem, LabeledContent, Link and Pressable take all of them; IconButton, Fab, Button, Avatar, Progress and ActivityIndicator take the ones they can use. They are named as React Native names them.
<HStack accessibilityHint="open the order" onPress={openOrder} spacing="s12">
<Icon name="shopping-bag" />
<Text>Order #1024</Text>
<Text color="content.secondary">Shipped</Text>
</HStack>
// VoiceOver: "Order #1024, Shipped, Button. Open the order."
// TalkBack: "Order #1024, Shipped, Button, double tap to open the order."
<IconButton accessibilityLabel="Share" name="share" onPress={share} />
<Text accessibilityRole="header" variant="title2">Orders</Text>
<Icon name="info" /> {/* no label: decorative, hidden from both readers */}| Prop | Type | Description |
|---|---|---|
| accessibilityHidden | boolean | Hides the view and everything in it from VoiceOver and TalkBack: a decorative image, a label read elsewhere. |
| accessibilityHint | string | What activating the view does, when its label alone does not say. Phrase it as an action ("open the photo"): iOS reads it after a pause, Android as TalkBack's "Double tap to open the photo", on a pressable view only. |
| accessibilityLabel | string | What a screen reader says for the view, in place of what it would read from its content. On a view read as one element ( |
| accessibilityRole | 'button' | 'header' | 'image' | 'link' | 'text' | What the view is. A stack with |
| accessibilityValue | string | Where the view stands, read after its label: "3 of 5", "On", "50 percent". |
| accessible | boolean | Reads the view and everything in it as one element: a row with a title and a subtitle, a card. |
Hides the view and everything in it from VoiceOver and TalkBack: a decorative image, a label read elsewhere.
What activating the view does, when its label alone does not say. Phrase it as an action ("open the photo"): iOS reads it after a pause, Android as TalkBack's "Double tap to open the photo", on a pressable view only.
What a screen reader says for the view, in place of what it would read from its content. On a view read as one element (accessible, a pressable stack) it replaces what the children would say.
What the view is. A stack with onPress is a button unless told otherwise; link is announced on iOS only.
Where the view stands, read after its label: "3 of 5", "On", "50 percent".
Reads the view and everything in it as one element: a row with a title and a subtitle, a card.
What the components do by themselves
- A stack with
onPressis one button: its children are read as one label, in order, and VoiceOver and TalkBack say “Button”.Pressableis a button too. ListItem,RnListItemand aLabeledContentwith avalueare read as one element.Linkis announced as a link on iOS.IconButton,FabandFabMenurequire anaccessibilityLabel: an icon has no words of its own.Icon,Avatarand the glyphs kara draws (a chevron, a close) are decorative and hidden unless they are given a label.Skeletonis always hidden: what is loading is for the screen to say.Snackbaris announced when it appears, and its close button is labelled.- The native controls (
Button,Switch,Checkbox,Slider,TextField,Picker,Stepper,SegmentedControl) are read by the platform from their own labels and values.
On each platform
| Prop | iOS | Android |
|---|---|---|
accessibilityLabel | accessibilityLabel | contentDescription, which replaces the children’s on an element read as one |
accessibilityHint | accessibilityHint, read after a pause | the click action’s label, “double tap to …”, on a pressable view only |
accessibilityRole | a trait: isButton, isHeader, isImage, isLink, isStaticText | Role.Button, Role.Image or heading(); no link role |
accessibilityValue | accessibilityValue | stateDescription |
accessible | accessibilityElement(children: .combine) | mergeDescendants |
accessibilityHidden | accessibilityHidden | clearAndSetSemantics {} |
Phrase a hint as an action (“open the order”): iOS reads it as a sentence of its own, Android after “double tap to”.
Reduce Motion
With Reduce Motion on (iOS) or Remove animations (Android), nothing in kara slides, scales, turns or loops, and what needs a transition to read fades instead, as Apple and Material both advise. The setting is read live: a user can flip it while the app is open.
| Component | Motion | With motion reduced |
|---|---|---|
entering, exiting, Stagger | the motion preset | every preset fades |
animate | colours, opacity and the transform | only the colours and the opacity |
Pressable | eases down to pressedScale | dims to pressedOpacity instead |
Snackbar | slide or scale in and out | fades |
Skeleton | pulses | holds still |
Collapsible (Android) | expands, the chevron turns | fades, the chevron snaps |
Stepper (iOS) | the value rolls to the next one | cross-fades |
The system controls, sheets and navigation transitions follow the setting themselves. KaraProvider reduceMotion overrides it: always for an app with a setting of its own or a screenshot run, never to ignore it.
Your own animations
The motion props honour the setting by themselves. For an animation of your own, useReducedMotion() says whether motion is reduced, by the device or by the provider. Time it with the duration and easing tokens, picked by what the motion does: fast and smoothOut for something arriving, quick for it leaving. timing(size, curve) is that as a withTiming config, with Reanimated’s own check off (ReduceMotion.Never): Reanimated reads the setting only at launch and never hears of the provider’s, so honour it yourself first.
import {timing, useReducedMotion} from '@hzblj/kara-ui'
import {useEffect} from 'react'
import {useSharedValue, withTiming} from 'react-native-reanimated'
const Badge = ({count}: {count: number}) => {
const reduced = useReducedMotion()
const scale = useSharedValue(1)
useEffect(() => {
if (reduced) {
return // the new number is enough; nothing pops
}
scale.value = 0.8
// Arriving: `fast` on `smoothOut`, with Reanimated's own check off: honoured above already
scale.value = withTiming(1, timing('fast'))
}, [count, reduced, scale])
// ...
}