Skip to content

Forms inside cards

A card is not required to be read-only content. If it holds a real form field, two pieces of engine behaviour, both already covered in Accessibility, are what make that work without any extra configuration on your part.

Neon Harbor
The Quiet Orbit
Salt and Static
Paper Moons
Last Train to Vela
Glasshouse
Northbound
Kite Season
1 / 8

Riffle checks the keydown’s own target before mapping arrow keys, Home or End to a stack move (see Accessibility). An <input>, <textarea>, <select>, or a contenteditable element is left alone entirely: the keydown reaches the field itself, so the caret moves and characters get typed exactly as they would outside a stack. There is nothing to opt into.

A background card’s field is unreachable, not just visually behind

Section titled “A background card’s field is unreachable, not just visually behind”

Every card except the active one is inert (see Accessibility). An <input> inside a background card cannot receive focus: not by Tab, not by a screen reader’s virtual cursor, not by a direct .focus() call from your own code. Advance the stack in the live demo above, then Tab through the page: only the front card’s field is ever reachable, and the note you typed on a card you have since navigated away from stays exactly where you left it, simply out of reach until that card is active again.

Where focus goes when the active card changes

Section titled “Where focus goes when the active card changes”

If a field inside the active card is focused and the stack moves to a new card, focus follows, exactly as Accessibility describes for any card’s content: it lands on the new active card’s own root element, never on a field inside it, and never the specific field you happened to be in before.

If you want focus to land inside the new card’s first field instead, put it there yourself, in your own change handler: the engine updates the new card’s accessibility state, including its own focus-follow onto that card’s root, before it fires its own change event, so your own redirect runs after the engine’s and is what sticks.

FormCardsStack.tsx
onChange={(event) => {
setActiveIndex(event.index)
// Only redirect focus into the new field when focus was already
// somewhere inside the stack: that is exactly the condition the
// engine's own focus-following uses to decide whether to move
// focus onto the new active card's root at all. Skipping this
// check would steal focus into a card field even when nothing on
// the page had focus in the stack, for example while a visitor
// was reading unrelated page text and something else called
// goTo().
const wasFocusInStack =
document.activeElement?.closest('[aria-roledescription="carousel"]') != null
if (!wasFocusInStack) return
// The engine updates the new active card's accessibility state,
// including its own focus-follow onto that card's root, before it
// fires this handler, so this call is the last one to move focus,
// not the first, and it is the one that sticks.
noteFieldRefs.current[event.index]?.focus()
}}
FormCardsStack.tsx
const inputId = `${idBase}-note-${index}`
return (
<div
style={{
width: CARD_WIDTH,
height: CARD_HEIGHT,
borderRadius: 16,
boxSizing: 'border-box',
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 8,
background: '#1c1c26',
color: '#f3efe8',
border: '1px solid rgb(255 255 255 / 8%)',
boxShadow: '0 24px 48px -16px rgb(0 0 0 / 60%), 0 2px 6px rgb(0 0 0 / 35%)',
}}
>
<strong>{film.title}</strong>
<label htmlFor={inputId} style={{ fontSize: '0.8rem' }}>
Your note
</label>
<input
id={inputId}
ref={(el) => {
noteFieldRefs.current[index] = el
}}
type="text"
placeholder={`What did you think of ${film.title}?`}
style={{ padding: 8, borderRadius: 8, border: '1px solid rgb(255 255 255 / 24%)' }}
/>
</div>
)

A few things worth calling out:

  • Each input’s id is built from useId() plus the card’s index, so <label htmlFor> genuinely targets the right field even though every card renders the same JSX. A hardcoded id would collide across cards, and across multiple stacks on one page.
  • The label is real markup, not a placeholder standing in for one. A placeholder disappears the moment you type and most screen readers do not treat it as a substitute for a label; every version of this recipe uses both, a real <label> for the accessible name and a placeholder for the example prompt text.
  • Nothing here is Riffle-specific. The stack does not know or care that its cards contain form fields; every behaviour on this page falls out of the roving-tabindex and inert mechanics that apply to any element inside a card, form field or not.

You have reached the end of the recipes. Examples is the full gallery if you want to see every shipped example in one place.