This article describes an approach for creating accessible Flip Cards — small blocks of content designed to resemble a playing card or business card, or similar two-sided thing. They have front and back “faces” and can be “flipped” with a 3D rotation effect.
There are many existing scripts and libraries that implement this effect, but none I’ve found have good enough accessibility, and not for want of trying. Creating an accessible pattern for this was challenging.
Demos and codebase
The problem with flip cards
The front and back faces are separate blocks of content within a parent container, using transform
and perspective
to create the appearance of a two-sided card that turns on its vertical axis. This is quite easy to make, but quite hard to make accessible, because there aren't any ARIA semantics that fit the pattern.
Screen reader users need accessible state information, so they know when the card is flipped, and which side is currently shown. However we can't use ARIA state attributes to provide this information, simply because there aren't any valid choices. States like aria-expanded
or aria-pressed
are not applicable, because they don't mean the same thing — the card is never expanded or collapsed, never pressed or not pressed — the behavior and presentation just aren't equivalent.
The button that triggers the flip could be used to provide accessible feedback, by changing the button's label or description. However dynamically changing a button's content isn't conveyed by screen readers, unless it's also accompanied by a semantic state change … which we can't use!
ARIA live regions are not a suitable replacement here, because they're only appropriate for status updates, not state information.
An old-school solution
Without an ARIA state change, the most reliable way to make screen readers announce a button, is to programmatically focus()
it. But in order to do that, the button first has to lose focus, yet we can't use blur()
because that resets the focus position back to the top of the page, causing screen readers to re-announce the page summary (e.g., the title and main heading).
Focus has to go somewhere, and it has to go somewhere that won't be announced:
- An invisible element is positioned and sized directly behind the button;
- When the button is pressed, focus shifts to this invisible element, which isn't announced because it has no conveyable content;
- Meanwhile, the button's accessible label and description are updated;
- Then the button is focused again, and this triggers its re-announcement.
The button's label has values like Flip to back
and Flip to front
, which denote its current action. State information is provided by the accessible description, such as Now showing the front
or Now showing the back
. The label and description are both announced when the button is programmatically focused.
Overall, this combination of dynamic label and description provides a functional label, a dynamic state update, and a persistent state summary.
HTML
The content of each card face can be any valid HTML, there are no explicit requirements or restrictions. However the surrounding markup is very specific, and must have the following node structure and required attributes:
<div id="MyID" role="group" aria-labelledby="MyID_1">
<aside hidden>
<span id="MyID_1">Flip Card</span>
<span id="MyID_2">Flip to front</span>
<span id="MyID_3">Flip to back</span>
<span id="MyID_4">Now showing the front</span>
<span id="MyID_5">Now showing the back</span>
</aside>
<div>
<button hidden aria-describedby="MyID_1"></button>
<div>
<!-- default content on the front face -->
</div>
<div>
<!-- flipped content on the back face -->
</div>
</div>
</div>
Required elements and attributes
The <div>
, <aside>
, and <span>
elements don't have to use those tags, any valid elements can be used. However their attributes must be as specified, and the button must be a <button>
.
- Group container
-
<div id="MyID" role="group" aria-labelledby="MyID_1">
The group container needs
role="group"
to provide a semantic context. The role doesn’t imply any other meaning or functionality, only that everything inside it is part of the same “thing”.The
aria-labelledby
attribute defines the group’s accessible name, and must reference theid
of the first language data element.The group
id
is used in the demo as an identifier for theFlipCard
constructor, and a syntactical namespace for other IDs. - Language data
-
<aside hidden> <span id="MyID_1">Flip Card</span> <span id="MyID_2">Flip to front</span> <span id="MyID_3">Flip to back</span> <span id="MyID_4">Now showing the front</span> <span id="MyID_5">Now showing the back</span> </aside>
The language container must include the
hidden
attribute, which prevents it from being visibly rendered or present in the accessibility tree (equivalent todisplay:none
in the user agent stylesheet, so it doesn’t rely on author CSS to remain hidden).The five child elements define individual language strings. They must have
id
attributes, and they must be defined in the same order:- Group name.
- Button label to flip the card to its front face.
- Button label to flip the card to its back face.
- Button description when the front face is displayed.
- Button description when the back face is displayed.
This content is not intended to be shown or directly announced, it only serves as reference data for
aria-labelledby
oraria-describedby
, or to be parsed by the script in order to generate dynamic labels. - Card faces
-
<div> ... <div> <!-- default content on the front face --> </div> <div> <!-- flipped content on the back face --> </div> </aside>
The parent container provides a positioning context, and defines the
perspective
size for 3D transforms:{ perspective: 64rem; position: relative; }
The first face element is considered to be the front, and is displayed by default. The faces can contain any valid markup, including interactive elements, but it’s recommended to start each one with a heading (e.g.,
<h2>
or<h3>
as applicable to the page structure). - Flip button
-
<button hidden aria-describedby="MyID_1"></button>
The flip button’s
hidden
attribute is removed by the script, and exists only to hide the button when scripting is unavailable or hasn’t loaded yet.The
aria-describedby
attribute must reference theid
of the first language data element. This will be dynamically updated when the card state changes, however iOS/VoiceOver doesn’t support those dynamic description updates, therefore the static value is necessary to provide some fallback context for users, as to what the button’s action is referring to.The button’s accessible name is also maintained by scripting, defined using
aria-label
, and rendered with CSS pseudo-content to provide a visible label:button::before { content: attr(aria-label); }
This approach provides more flexibility when styling the flip button, and since the values it’s derived from are already present as in-page text, there are no translation issues with using
aria-label
in this case.
CSS
The flip card has two static states — the front face is displayed, or the back face is displayed — and it flips between them by using transition
to animate rotational transform
on the faces' container.
This requires two separate transforms. If we only applied rotation to the container, then flipping from front to back would result in the back face being reversed (like a mirror image). But if we also apply rotation to the back face itself, then they cancel each other out by the end of the transition:
@media (prefers-reduced-motion: no-preference) {
[data-flip-state] {
perspective: 64rem;
}
[data-flip-state] > .faces {
transform: rotateY(0deg);
transition: transform 0.5s;
}
[data-flip-state~="flipped"] > .faces {
transform: rotateY(180deg);
}
[data-flip-state] > .faces > .back {
transform: rotateY(180deg);
}
}
Most flip card implementations use absolute positioning and fixed dimensions, where the faces are stacked on top of each other and visibly rotate together. However that means the content can't be responsive to changes in font-size or text spacing, which might cause the layout to reflow and make one or both faces overflow the container.
This pattern does something different to avoid that problem — the two card faces are never displayed at the same time — so they don't need fixed dimensions or absolute positioning, they can freely adapt to reflow, and they don't even have to be the same size.
The trick here is that the displayed face changes in the middle of the transition (when rotation is 90°), which is not visually apparent because neither face is visible for that precise moment (like looking at the side of a playing card).
Styling hooks
The demo uses class
names for each container element, but these are not required or even identified by the script, so you can do whatever you want:
<div class="card">
...
<div class="faces">
...
<div class="front">
...
</div>
<div class="back">
...
</div>
</div>
</div>
The script provides a dynamic [data-flip-state]
attribute, which can be used as an attribute selector for state-dependent styling. For example, the following rule controls which face is currently displayed:
[data-flip-state~="front"] > .faces > .back,
[data-flip-state~="back"] > .faces > .front {
display: none;
}
This attribute has four possible token values, and at any given moment it might have multiple tokens:
Token | Meaning |
---|---|
"front" | The front face is currently displayed. |
"back" | The back face is currently displayed. |
"flipped" | The card is flipped to its back face. |
"busy" | The card is currently transitioning between states. |
Flipping from front to back will pass through the following states:
"front"
"front flipped busy"
"back flipped busy"
"back flipped"
Whereas flipping from back to front will do the reverse:
"back flipped"
"back busy"
"front busy"
"front"
Alternate button designs
The demo stylesheet uses pseudo-content to create a visible label from the button's aria-label
attribute:
.faces > button::before {
content: attr(aria-label);
}
This approach is recommended for the sake of accessible visual affordance — it clearly conveys the function to sighted users, and also provides a command cue for speech recognition. Users can press the button with a spoken command like Click flip to front
(which still works without a visible label, but then how would they know what to say). However that provision is not absolutely critical, since users have other ways to reach and interact with controls (e.g., using the Show Numbers
function in Voice Control for macOS and iOS).
One alternative presentation could be to use an icon, or an empty dot-like button:
.faces > button {
border-radius: 1rem;
height: 1rem;
width: 1rem;
}
.faces > button::before {
content: none;
}
Another possibility is to style the button so it's completely transparent and covers the entire card. In this case it's essential to prevent the button from receiving pointer events, otherwise it wouldn't be possible to select text or interact with the card content. For sighted keyboard users, it will seem as though the card itself is in the focus order, while the button's action is still effectively available to pointer users, since they can also trigger a flip by clicking the card:
.faces > button {
background: transparent;
border: none;
border-radius: 0.6rem;
height: 100%;
margin: 0;
pointer-events: none;
width: 100%;
}
.faces > button::before {
content: none;
}
.faces > button:disabled {
filter: none;
opacity: 1;
}
Testing the focus hook element
The focus hook is the element that receives focus during a card transition. The focus hook isn't announced or visibly displayed, but its :focus-visible
state is used to maintain the flip button's outline
, when the button itself isn't focused:
.card > [tabindex="-1"]:not([hidden]) {
display: inline-block;
overflow: hidden;
pointer-events: none;
position: absolute;
z-index: -1;
}
.card > [tabindex="-1"]:focus {
outline: none;
}
[tabindex="-1"]:focus-visible + .faces > button {
outline: 0.15rem solid var(--outline-color);
outline-offset: 0.15rem;
}
If you're making significant changes to the flip card's structural styling, then it may be helpful to make the focus hook temporarily visible, to confirm that its layout position is correct, for example:
.card > [tabindex="-1"]:not([hidden]) {
background: rgba(255,0,0,0.25);
outline: 0.3rem solid #f00;
z-index: 1;
}
JavaScript
The FlipCard
class is structured as an export module, or you can convert it to a regular class declaration if that's preferred. Any number of instances can exist on a single page, each of which is created using the FlipCard
constructor:
const demo = new FlipCard("#demo");
The argument is a selector query, that specifies a single group container element.
Cards with interactive content
Pointer users can trigger a flip by clicking the whole card, however this could potentially conflict with events on other interactive content (e.g., buttons or links inside the card). To avoid that problem, the whole-card click only applies if the pointer target isn't an interactive element, as specified by a second argument to the FlipCard
constructor:
const demo = new FlipCard("#demo", "a[href], button, input");
The argument is a selector query, that should specify any interactive elements the card might contain (or defaults to "button"
if undefined).
So whole-card clicks are conditionally ignored, if the pointer target is (or is inside) any element that matches the specified selector. They're also ignored if the pointer action resulted in text selection.
How the rotation is monitored
The displayed face changes in the middle of the transition, so we have to monitor the transform in real-time to know when it reaches that point. It needs to look like we're viewing two sides of the same card, and any obvious display swap would undermine that illusion.
This information is not provided by native APIs (CSS transitions and animations don't provide a frame event), but we can create a custom method to monitor their changes, with a combination of requestAnimationFrame()
and getComputedStyle()
.
The following code retrieves the matrix3d
string from computed style, converts it to a DOM Matrix, then calculates the rotation angle in degrees:
const matrix = new DOMMatrixReadOnly(this.css.getPropertyValue('transform'));
const rotation = Math.acos(matrix.a) * (180 / Math.PI);
This is called from a function triggered by requestAnimationFrame()
, which is very unlikely to ever occur at precisely 90°, so we check for the point when it's only just greater or less, depending on the flip direction:
if((flags.back && rotation >= 90) || (flags.front && rotation <= 90)) {
//swap the displayed face
}
Else if it's not close enough, then iterate again on requestAnimationFrame()
, until it is.
How the focus hook is silent
This is a bit of a hack! But it's logically sound, semantically valid, and more to the point — it works.
Depending on the screen reader (they all vary in this respect), focusing an empty element would cause the parent group to be re-announced, or the element announced as blank
, and this could still occur even with whitespace-only text content. Whitespace is still required so that the element isn't empty, but its announcement behavior can be overridden by defining an aria-roledescription
that can't be announced.
This is achieved with a unicode non-character, which is technically not allowed to exist in a document at all, it's designed to be used programmatically for things like temporary parsing delimiters. It's impossible to announce, because it isn't mapped to a character, so the end result is that nothing is announced:
<span role="note" aria-roledescription="" tabindex="-1"> </span>
That’s all from me! (from here)
With thanks to Aditya Jainapur, Hans Hillen, Ricky Onsman, Doug Abrams, Tj Squires, and Isabel Holdsworth, for their useful ideas and feedback during development. I welcome any thoughts, ideas, or feedback you might have on the concepts and solutions, or anything else.
Here are the demos and codebase links again:
Image credit: Resource Database.
Comments