An Accordion component is a header which can be clicked or pressed to show and hide a section of related content.
Read more Accordion's use cases and when not to use it
Before we go into the implementation details, let's look at each individual element which combine to form an Accordion component.
An Accordion consists of three elements:
- Header - which can be pressed to reveal the content.
- Icon - which changes based on the state (show and hide) - An Accordion uses the icon to provide a visual affordance to user.
- Main content - which is displayed when the header is pressed.
Each element of the Accordion component. Header is highlighted with Green, icon with blue and the main content with red.
To be able to have the control over how to render each of Accordion's element i.e header and the content, we will be structuring the API in this manner -
<Accordion
label="Payment settings"
open={true}
content={<Text>Your payment settings</Text>}
/>
In the above example, we can see that the Accordion includes three props:
label
to set the label text for headercontent
which accepts a component to render the main content when the header is pressed.open
to set the Accordion to be open by default.
To create the touchable header, we will be using the TouchableHighlight component. The touchable header component has two things to do -
- showing the header text,
- and updating the Accordion's state i.e whether it is open or closed when it is pressed.
The touchable header will have two variants:
- Standard - This will be the default variant
- Expanded - This will be rendered when the Accordion is in open state.
Difference between standard and expanded variant The key difference between both the variants is that, the latter is used to provide a visual affordance to a user. When the Accordion is in the default state i.e closed, it shows the plain header but when it is opened, it highlights the header section.
Let's draw the basic markup of the touchable header component.
import * as React from 'react';
import {
View,
Text,
TouchableHighlight,
} from 'react-native';
import Icon from 'react-native-vector-icons/AntDesign';
const TouchableHeader = (props) => {
/**
* Switch between different icons depending upon the Accordion's state.
*/
const iconName = props.isOpen ? 'minus' : 'plus';
return (
<TouchableHighlight onPress={props.onPress}>
<View>
<Text>{props.label}</Text>
<Icon name={iconName} size={16} color="#003366" />
</View>
</TouchableHighlight>
);
};
To summarise, we have created a touchable header component that receives three props -
isOpen
which tells us whether the Accordion is open or closed.label
for showing the header textonPress
callback, which will set the Accordion's state on press.
Let's include some basic styling as well to ensure that each element is at the appropriate place.
import * as React from 'react';
import {
View,
Text,
TouchableHighlight,
StyleSheet
} from 'react-native';
import Icon from 'react-native-vector-icons/AntDesign';
const TouchableHeader = (props) => {
/**
* Set a bottom border when the accordion is opened. It ensures
* that there is clear separation between header and the content
* area.
*/
const touchableViewBorderWidth = props.isOpen ? 1 : 0;
/**
* Set a background color for the accordion when it is opened.
* This helps provide visual affordance to a user.
*/
const headerBackgroundColor = props.isOpen ? '#F2F2F2' : 'none';
/**
* Switch between different icons depending upon the Accordion's state.
*/
const iconName = props.isOpen ? 'minus' : 'plus';
return (
<TouchableHighlight
onPress={props.onPress}
style={[
styles.touchableView,
{ borderBottomWidth: touchableViewBorderWidth },
]}>
<View
style={[
styles.headerWrapperStyles,
{ backgroundColor: headerBackgroundColor },
]}>
<Text style={styles.labelStyle}>{props.label}</Text>
<Icon name={iconName} size={16} color="#003366" />
</View>
</TouchableHighlight>
);
};
const styles = StyleSheet.create({
// Styles for the header section
headerWrapperStyles: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: 'row',
padding: 12,
},
// Base styles for the touchable highlight component
touchableView: {
borderColor: '#F2F2F2',
borderRadius: 2,
},
// Styles for the header text
labelStyle: {
fontWeight: 'bold',
fontSize: 15,
color: '#003366',
},
});
Great! We have now created the touchable header component which -
- shows the header text and the icon
- renders different variants based on the state i.e open or closed
With the above styling and markup, our touchable header component should look like this -
Let's create a component that will show the main content when the Accordion is in open state.
import * as React from 'react';
import {
View,
StyleSheet
} from 'react-native';
const Content = (props) => {
if (props.isOpen) {
return <View style={styles.accordionContent}>{props.children}</View>;
}
return null;
};
const styles = StyleSheet.create({
accordionContent: {
padding: 12,
},
});
Easy, right?
Now let's merge both, the header and content component to create the Accordion. First, we will need to add a local state variable to keep track of Accordion's state.
/**
* State to track whether the expander is open or closed.
* The default state can also be controlled using the "open" prop.
*/
const [isOpen, setIsOpen] = React.useState(props.open || false);
Second, we will need a callback which will be invoked when the header is pressed, and will be used to update the state.
const onPress = () => {
setIsOpen(!isOpen);
};
Final step is to render the markup.
const Accordion = (props) => {
/**
* State to track whether the expander is open or closed.
*/
const [isOpen, setIsOpen] = React.useState(props.open || false);
/**
* Invoked when the header element is pressed.
*/
const onPress = () => {
setIsOpen(!isOpen);
};
return (
<View style={styles.accordionWrapper}>
<TouchableHeader
onPress={onPress}
isOpen={isOpen}
label={props.label}
/>
<Content isOpen={isOpen}>{props.content}</Content>
</View>
);
};
const styles = StyleSheet.create({
accordionWrapper: {
borderColor: BORDER_COLOR,
borderRadius: 2,
borderWidth: 1,
},
app: {
display: 'flex',
justifyContent: 'center',
margin: 10
}
});
Nice! Now let's render the Accordion component and see how it would like.
const App = () => (
<View style={styles.app}>
<Accordion
label="Payment settings"
content={<Text>Your payment settings</Text>}
/>
</View>
);
const styles = StyleSheet.create({
app: {
display: 'flex',
justifyContent: 'center',
margin: 10
}
});
https://snack.expo.dev/embedded/@nitintulswani/react-native-accordion?preview=true
As a sighted user, the design of the Accordion indicates to me that it is an interactive/clickable control. But as a screen reader user, I won't be able to know if the Accordion component is interactive or clickable which creates confusion.
To solve this, we can use the prop accessibilityRole
which defines a role to provide context on the currently focused element. Since our Accordion is interactive (shows and hides content view on press) and clickable, the most appropriate role attribute that we can use is button
const TouchableHeader = (props) => {
...
...
return (
<TouchableHighlight accessibilityRole="button" {...props}>
...
</TouchableHighlight>
)
}
Without the prop accessibilityRole
, when the Accordion is focused, the screen reader would just announce the header text -
Screen reader: Payment settings
As we can see, just announcing the header text when the element is focused is not very helpful because I don't know if the element is interactive or not. But when the accessibilityRole
prop is defined, the screen reader would announce -
Screen reader: Payment settings, button
So now I know that the element is a clickable and it also gave me a hint that what might happen upon the interaction.
Again, as a sighted user, I had the visual affordance of the icon (minus and plus) in the header indicating the purpose i.e when the Accordion is in open state, it shows minus icon and when it is in close state, it shows plus icon. But the screen reader won't convey this information, so let's fix that.
Let's add the prop accessibilityLabel
to define the label.
const TouchableHeader = (props) => {
...
...
return (
<TouchableHighlight accessibilityLabel={props.label} accessibilityRole="button" {...props}>
...
</TouchableHighlight>
)
}
Now, we need to update the label text in such a way that when the Accordion is in close state, the screen reader announces Open payment settings, button
and when it is in open state, it announces Close payment settings, button
.
To do that, let's define prefixes for our label -
const a11yLabelPrefixes = {
open: 'Open',
close: 'Close',
};
Next, we need to update the label based on the state -
const a11yLabelPrefixes = {
open: 'Open',
close: 'Close',
};
const TouchableHeader = (props) => {
...
...
const a11yLabelPrefix = a11yLabelPrefixes[props.isOpen ? 'close' : 'open'];
const accessibilityLabel = `${a11yLabelPrefix} ${props.a11yLabel}`
return (
<TouchableHighlight accessibilityLabel={accessibilityLabel} {...props}>
...
</TouchableHighlight>
)
}
So when the Accordion is in close state, screen reader will announce the label as -
Open payment settings, button
and when it is in open state, screen reader will announce the label as -
Close your payment settings, button
Defining the label and role via props accessibilityLabel
and accessibilityRole
, makes it easy for a user to understand the purpose of the control (Accordion) and gives a hint that what might happen when I interact with the element.
Thanks for reading. Let me know if you found the blog post helpful!