diff --git a/README.md b/README.md
index d46a364..dfb4ed8 100644
--- a/README.md
+++ b/README.md
@@ -564,6 +564,7 @@ return (
| padSecondsWithZero | Pad single-digit seconds in the picker with a zero | Boolean | true | false |
| padWithNItems | Number of items to pad the picker with on either side | Number | 1 | false |
| aggressivelyGetLatestDuration | Set to True to ask DurationScroll to aggressively update the latestDuration ref | Boolean | false | false |
+| accessibilityLabels | Custom accessibility labels for each picker column. Supports `days`, `hours`, `minutes`, `seconds`, and `hint` keys | `{ days?: string, hours?: string, minutes?: string, seconds?: string, hint?: string }` | - | false |
| allowFontScaling | Allow font in the picker to scale with accessibility settings | Boolean | false | false |
| use12HourPicker | Switch the hour picker to 12-hour format with an AM / PM label | Boolean | false | false |
| amLabel | Set the AM label if using the 12-hour picker | String | am | false |
@@ -653,6 +654,42 @@ Please note that this solution does not work for all bottom-sheet components (e.
**Important**:
The custom component needs to have the same interface as React Native's `` in order for it to work as expected. A complete reference of the current usage can be found [here](/src/components/DurationScroll/index.tsx).
+#### Accessibility ♿
+
+The TimerPicker component supports VoiceOver (iOS) and TalkBack (Android) screen readers. When a screen reader is enabled, users can:
+
+- Navigate to each picker column (days, hours, minutes, seconds)
+- Swipe up to increment the value
+- Swipe down to decrement the value
+- Hear immediate announcements of the new value after each adjustment
+
+**Basic Usage:**
+
+The component automatically detects when a screen reader is active and adjusts its behaviour accordingly. No additional configuration is required for basic accessibility support.
+
+**Custom Labels:**
+
+You can customise the accessibility labels for each picker column using `accessibilityLabels`:
+
+```jsx
+
+```
+
+**How it works:**
+
+- When a screen reader is **disabled**, users interact with the picker normally by scrolling
+- When a screen reader is **enabled**, each picker column becomes an "adjustable" element that responds to swipe gestures
+- Screen reader announcements include the unit label (e.g. "5 hours", "30 minutes")
+- 12-hour format announcements include the AM/PM period (e.g. "5 pm")
+
### TimerPickerModal ⏰
The TimerPickerModal component accepts all [TimerPicker props](#timerpicker-️), and the below additional props.
diff --git a/src/components/DurationScroll/DurationScroll.tsx b/src/components/DurationScroll/DurationScroll.tsx
index e89775d..1901d55 100644
--- a/src/components/DurationScroll/DurationScroll.tsx
+++ b/src/components/DurationScroll/DurationScroll.tsx
@@ -8,7 +8,7 @@ import React, {
useMemo,
} from "react";
-import { View, Text, FlatList as RNFlatList } from "react-native";
+import { View, Text, FlatList as RNFlatList, AccessibilityInfo } from "react-native";
import type { ViewabilityConfigCallbackPairs, FlatListProps } from "react-native";
import { colorToRgba } from "../../utils/colorToRgba";
@@ -25,6 +25,8 @@ const keyExtractor = (item: any, index: number) => index.toString();
const DurationScroll = forwardRef((props, ref) => {
const {
+ accessibilityHint,
+ accessibilityLabel,
aggressivelyGetLatestDuration,
allowFontScaling = false,
amLabel,
@@ -33,11 +35,13 @@ const DurationScroll = forwardRef((props
decelerationRate = 0.88,
disableInfiniteScroll = false,
FlatList = RNFlatList,
+ formatValue,
Haptics,
initialValue = 0,
interval,
is12HourPicker,
isDisabled,
+ isScreenReaderEnabled = false,
label,
limit,
LinearGradient,
@@ -176,6 +180,13 @@ const DurationScroll = forwardRef((props
const [clickSound, setClickSound] = useState(null);
+ // Track the current value text for the accessibility value prop (state so it
+ // is always in sync — reading latestDuration.current inside a useMemo would
+ // be stale since refs don't trigger re-renders).
+ const [accessibilityValueText, setAccessibilityValueText] = useState(() =>
+ formatValue ? formatValue(initialValue) : String(initialValue)
+ );
+
useEffect(() => {
// Audio prop deprecated in v2.2.0 (use pickerFeedback instead) - will be removed in a future version
@@ -474,6 +485,55 @@ const DurationScroll = forwardRef((props
[styles.pickerItemContainer.height]
);
+ const handleAccessibilityAction = useCallback(
+ (event: { nativeEvent: { actionName: string } }) => {
+ const { actionName } = event.nativeEvent;
+
+ let newValue: number;
+
+ if (actionName === "increment") {
+ newValue = latestDuration.current + interval;
+ if (newValue > adjustedLimited.max) {
+ newValue = adjustedLimited.min;
+ }
+ } else if (actionName === "decrement") {
+ newValue = latestDuration.current - interval;
+ if (newValue < adjustedLimited.min) {
+ newValue = adjustedLimited.max;
+ }
+ } else {
+ return;
+ }
+
+ flatListRef.current?.scrollToIndex({
+ animated: true,
+ index: getInitialScrollIndex({
+ disableInfiniteScroll,
+ interval,
+ numberOfItems,
+ padWithNItems,
+ repeatNumbersNTimes: safeRepeatNumbersNTimes,
+ value: newValue,
+ }),
+ });
+ latestDuration.current = newValue;
+
+ const announcement = formatValue ? formatValue(newValue) : String(newValue);
+ setAccessibilityValueText(announcement);
+ AccessibilityInfo.announceForAccessibilityWithOptions(announcement, { queue: false });
+ },
+ [
+ adjustedLimited.max,
+ adjustedLimited.min,
+ disableInfiniteScroll,
+ formatValue,
+ interval,
+ numberOfItems,
+ padWithNItems,
+ safeRepeatNumbersNTimes,
+ ]
+ );
+
useImperativeHandle(ref, () => ({
latestDuration: latestDuration,
reset: (options) => {
@@ -507,6 +567,7 @@ const DurationScroll = forwardRef((props
data={numbersForFlatList}
decelerationRate={decelerationRate}
getItemLayout={getItemLayout}
+ importantForAccessibility={isScreenReaderEnabled ? "no-hide-descendants" : undefined}
initialScrollIndex={initialScrollIndex}
keyExtractor={keyExtractor}
nestedScrollEnabled
@@ -526,7 +587,13 @@ const DurationScroll = forwardRef((props
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
windowSize={numberOfItemsToShow}
/>
-
+
{typeof label === "string" ? (
{label}
@@ -545,6 +612,7 @@ const DurationScroll = forwardRef((props
getItemLayout,
initialScrollIndex,
isDisabled,
+ isScreenReaderEnabled,
label,
labelPositionStyle,
numberOfItemsToShow,
@@ -607,6 +675,14 @@ const DurationScroll = forwardRef((props
return (
string;
Haptics?: any;
initialValue?: number;
interval: number;
is12HourPicker?: boolean;
isDisabled?: boolean;
+ isScreenReaderEnabled?: boolean;
label?: string | React.ReactElement;
limit?: Limit;
LinearGradient?: any;
diff --git a/src/components/TimerPicker/TimerPicker.tsx b/src/components/TimerPicker/TimerPicker.tsx
index 43af37c..b719786 100644
--- a/src/components/TimerPicker/TimerPicker.tsx
+++ b/src/components/TimerPicker/TimerPicker.tsx
@@ -1,5 +1,6 @@
import React, {
forwardRef,
+ useCallback,
useEffect,
useImperativeHandle,
useMemo,
@@ -10,6 +11,7 @@ import React, {
import { View } from "react-native";
import { getSafeInitialValue } from "../../utils/getSafeInitialValue";
+import { useScreenReaderEnabled } from "../../utils/useScreenReaderEnabled";
import DurationScroll from "../DurationScroll";
import type { DurationScrollRef } from "../DurationScroll";
import { generateStyles } from "./styles";
@@ -31,8 +33,24 @@ const resolvePerColumn = (
return value[column];
};
+// Pure utility — defined outside the component so it is never recreated on
+// render and DurationScroll's memoization is not broken.
+const formatAccessibilityValue = (
+ value: number,
+ unitLabel: string,
+ options?: { is12HourPicker?: boolean; amLabel?: string; pmLabel?: string }
+): string => {
+ if (options?.is12HourPicker) {
+ const hour12 = value === 0 ? 12 : value > 12 ? value - 12 : value;
+ const period = value < 12 ? options.amLabel ?? "am" : options.pmLabel ?? "pm";
+ return `${hour12} ${period}`;
+ }
+ return `${value} ${unitLabel}`;
+};
+
const TimerPicker = forwardRef((props, ref) => {
const {
+ accessibilityLabels,
aggressivelyGetLatestDuration = false,
allowFontScaling = false,
amLabel = "am",
@@ -80,6 +98,8 @@ const TimerPicker = forwardRef((props, ref) =>
...otherProps
} = props;
+ const isScreenReaderEnabled = useScreenReaderEnabled();
+
useEffect(() => {
if (otherProps.Audio) {
console.warn(
@@ -206,17 +226,54 @@ const TimerPicker = forwardRef((props, ref) =>
},
}));
+ // Accessibility format functions — stable references via useCallback so they
+ // don't break DurationScroll's memoization. No zero-padding: screen readers
+ // announce "5 hours", not "05 hours".
+ const formatDayA11y = useCallback(
+ (value: number) => formatAccessibilityValue(value, accessibilityLabels?.days ?? "days"),
+ [accessibilityLabels?.days]
+ );
+
+ const formatHourA11y = useCallback(
+ (value: number) =>
+ formatAccessibilityValue(value, accessibilityLabels?.hours ?? "hours", {
+ is12HourPicker: use12HourPicker,
+ amLabel,
+ pmLabel,
+ }),
+ [accessibilityLabels?.hours, use12HourPicker, amLabel, pmLabel]
+ );
+
+ const formatMinuteA11y = useCallback(
+ (value: number) => formatAccessibilityValue(value, accessibilityLabels?.minutes ?? "minutes"),
+ [accessibilityLabels?.minutes]
+ );
+
+ const formatSecondA11y = useCallback(
+ (value: number) => formatAccessibilityValue(value, accessibilityLabels?.seconds ?? "seconds"),
+ [accessibilityLabels?.seconds]
+ );
+
return (
-
+
{!hideDays ? (
((props, ref) =>
{!hideHours ? (
((props, ref) =>
{!hideMinutes ? (
((props, ref) =>
{!hideSeconds ? (
(
safeInitialValue.seconds,
]);
+ // announce the picker to screen readers when the modal opens
+ useEffect(() => {
+ if (visible) {
+ AccessibilityInfo.announceForAccessibility(modalTitle ?? "Time picker");
+ }
+ }, [visible, modalTitle]);
+
const hideModalHandler = () => {
setSelectedDuration({
days: confirmedDuration.days,
@@ -170,7 +177,7 @@ const TimerPickerModal = forwardRef(
{modalTitle ? (
-
+
{modalTitle}
) : null}
@@ -193,7 +200,12 @@ const TimerPickerModal = forwardRef(
onPress: cancelHandler,
})
) : (
-
+
{cancelButtonText}
)
@@ -203,7 +215,12 @@ const TimerPickerModal = forwardRef(
onPress: confirmHandler,
})
) : (
-
+
{confirmButtonText}
)}
diff --git a/src/utils/useScreenReaderEnabled.ts b/src/utils/useScreenReaderEnabled.ts
new file mode 100644
index 0000000..5428745
--- /dev/null
+++ b/src/utils/useScreenReaderEnabled.ts
@@ -0,0 +1,26 @@
+import { useState, useEffect } from "react";
+
+import { AccessibilityInfo } from "react-native";
+
+export const useScreenReaderEnabled = (): boolean => {
+ const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false);
+
+ useEffect(() => {
+ AccessibilityInfo.isScreenReaderEnabled().then((screenReaderEnabled) => {
+ setIsScreenReaderEnabled(screenReaderEnabled);
+ });
+
+ const subscription = AccessibilityInfo.addEventListener(
+ "screenReaderChanged",
+ (screenReaderEnabled) => {
+ setIsScreenReaderEnabled(screenReaderEnabled);
+ }
+ );
+
+ return () => {
+ subscription?.remove();
+ };
+ }, []);
+
+ return isScreenReaderEnabled;
+};