Casca Accessibility GuideLink to section
WCAG 2.1 AA ComplianceLink to section
Casca is designed with accessibility as a first-class requirement, targeting WCAG 2.1 Level AA compliance out of the box. This applies to both the read-only charts (visual + a .casca-data text alternative) and the interactive controls (slot grid, data table, range slider, stat card) and no-JS interaction primitives (toggle, switch, disclosure, filter), which are built on native HTML so they are keyboard- and screen-reader-accessible by default. See "Controls & Interactions Accessibility" below.
Core Accessibility FeaturesLink to section
1. Semantic HTML StructureLink to section
All charts use proper semantic HTML:
<!-- Proper figure/figcaption for charts -->
<figure class="casca casca-figure" role="img" aria-labelledby="chart-title">
<figcaption>
<div id="chart-title" class="casca-title">Monthly Revenue</div>
</figcaption>
<!-- Chart visualization -->
</figure>
Benefits:
- Screen readers announce charts as images with descriptive titles
- Clear document structure and landmarks
- Proper heading hierarchy
2. Data Table AlternativeLink to section
Every chart should include an accessible data table for screen readers:
<table class="casca-data">
<caption>Monthly Revenue Data</caption>
<thead>
<tr>
<th>Month</th>
<th>Revenue</th>
</tr>
</thead>
<tbody>
<tr><td>January</td><td>$50,000</td></tr>
<tr><td>February</td><td>$65,000</td></tr>
</tbody>
</table>
<div class="casca-bar" aria-hidden="true">
<!-- Visual chart here -->
</div>
Key Points:
- Data table is visually hidden but accessible to screen readers
- Visual chart is marked
aria-hidden="true"to avoid duplication - Table provides structured data alternative
- CSS class
.casca-dataautomatically hides table visually
3. Keyboard NavigationLink to section
All interactive elements are fully keyboard accessible:
Focus Indicators:
:focus-visible {
outline: 3px solid var(--casca-color-1);
outline-offset: 3px;
}
Navigation:
Tab- Move between interactive elementsEnter/Space- Activate buttonsArrow keys- Navigate radio buttons (paginated legends)
Interactive Elements:
- Bar segments with hover labels
- Pie chart segments
- Paginated legend controls
- Charts in buttons/links
4. Color and ContrastLink to section
Meets WCAG AA contrast requirements:
- Text on backgrounds: Minimum 4.5:1 ratio
- Large text (18pt+): Minimum 3:1 ratio
- UI components: Minimum 3:1 ratio
Color Independence:
- Never rely on color alone to convey information
- Use labels, patterns, or text in addition to color
- Dark mode support maintains contrast ratios
High Contrast Mode:
@media (prefers-contrast: more) {
:root {
--casca-line-stroke-width: 3px;
--casca-bar-min-height: 4px;
--casca-axis-width: 2px;
}
.casca-bar-value:hover,
.casca-pie-chart:hover {
outline: 3px solid;
outline-offset: 2px;
}
}
5. Reduced Motion SupportLink to section
Respects user motion preferences:
@media (prefers-reduced-motion: reduce) {
.casca,
.casca * {
--casca-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
What this does:
- Disables all animations for users who prefer reduced motion
- Prevents vestibular issues and motion sickness
- Instant transitions instead of animated ones
6. Screen Reader SupportLink to section
Tested with:
- NVDA (Windows)
- JAWS (Windows)
- VoiceOver (macOS/iOS)
- TalkBack (Android)
Best Practices:
<!-- Descriptive labels -->
<div class="casca-title">Monthly Cash Flow</div>
<div class="casca-subtitle">Income vs Expenses</div>
<!-- Live regions for dynamic updates -->
<div class="casca-live-region" aria-live="polite" aria-atomic="true">
Chart updated: New data available
</div>
<!-- Descriptive data labels -->
<div class="casca-bar-value"
data-label="January: $50,000"
aria-label="January: $50,000">
</div>
7. Touch Target SizesLink to section
Minimum 44×44 CSS pixels for all interactive elements:
.casca-legend-nav label {
min-width: var(--casca-size-6); /* 2rem = 32px */
padding: var(--casca-size-1) var(--casca-size-2);
/* Effective size: ~44×36px */
}
.casca-bar-value {
min-height: var(--casca-bar-min-height);
width: var(--casca-size-6); /* 32px */
/* Clickable area enhanced via padding/margin */
}
8. Forced Colors ModeLink to section
Windows High Contrast Mode support:
@media (forced-colors: active) {
.casca-bar-value,
.casca-line polyline,
.casca-pie-chart {
forced-color-adjust: none;
border: 1px solid canvastext;
}
.casca-line-point {
fill: canvastext;
stroke: canvas;
}
}
Prose Extensions AccessibilityLink to section
Prose callouts, disclosures, definition lists, and heading anchors are scoped to
.casca-prose and preserve native HTML semantics.
CalloutsLink to section
Use the explicit callout form when the intent is more specific than a note:
<div class="casca-prose-callout" data-callout="danger" role="note" aria-label="Danger">
<p><strong>Do not delete the source data.</strong> This action cannot be reconstructed.</p>
</div>
The CSS uses data-callout only for visual treatment. Authors should add
role="note" because <div> has no implicit role, and should add an
aria-label matching the intent: Note, Tip, Warning, Danger, or
Important. Localized label text is fine.
Auto-detected callouts use a bold-leading blockquote:
<blockquote>
<p><strong>Note.</strong> This renders as a note callout.</p>
</blockquote>
The blockquote keeps its native semantics, so no additional ARIA is required. The auto-detected path always renders as a note. To opt out for a real quotation that happens to start with bold text, wrap the bold run so it is not the first child of the first paragraph:
<blockquote>
<p><span><strong>Hello,</strong></span> she said.</p>
</blockquote>
Callout meaning must be present in text, not color alone. Keep a visible bold
lead-in such as Warning. or Important. inside the body. In forced-colors
mode Casca drops tinted backgrounds to system colors, keeps the leading accent
stripe, and preserves the text signal. In print, callouts stay visible and use
print-color-adjust: exact for the tinted background where printers allow it.
Details and Definition ListsLink to section
<details> and <summary> keep their native disclosure behavior. Enter and
Space toggle the disclosure, the summary remains the focusable control, and the
summary text remains the accessible label. Casca hides the browser marker with
list-style: none plus ::-webkit-details-marker and draws a decorative marker
that rotates. Reduced-motion users get the state change without animation.
Definition lists use plain <dl>, <dt>, and <dd>. Styling changes weight,
font, spacing, and indent only; the term and definition association remains in
the HTML.
Heading AnchorsLink to section
The SSG emits heading anchors as real links:
<a class="casca-prose-anchor" href="#callouts"><span class="casca-prose-anchor-label">Link to section</span></a>
The .casca-prose-anchor-label span supplies the accessible name. It is
visually hidden with the same clip-rect pattern as .sr-only, plus
clip-path: inset(50%), and remains available to assistive technology. The
visible # glyph is decorative CSS ::after content.
Heading anchors are in the normal Tab order. They reveal on heading hover,
anchor hover, :focus-visible, and the :focus fallback. On touch-only
devices, @media (hover: none) reveals anchors at rest so the permalink
affordance is discoverable without hover. In forced-colors mode anchors are
always visible and use LinkText. In print, the anchors are hidden because the
glyph is interactive-only.
<casca-layout> AccessibilityLink to section
<casca-layout> uses Declarative Shadow DOM for the chrome and light-DOM slots
for consumer content. Focus order follows the slot positions in the shadow
template, not the order of slotted nodes in the light DOM. In sequence, the
template places the skip link first, then brand, nav, theme picker, landing
hero when rendered, main content, and footer. Authors should not rely on light
DOM source order to define keyboard order.
Landmark roles come from native element semantics. The shadow template uses
<header>, <nav>, <main>, and <footer> without explicit role=
attributes. Slotted content is exposed in the composed accessibility tree
inside those landmarks.
The skip link has visible text, Skip to main content, which is its accessible
name. Its href targets #casca-layout-main in light DOM. The consumer's
main-slot wrapper must carry both id="casca-layout-main" and
tabindex="-1" so activation transfers focus without inserting the wrapper
into sequential Tab order.
Slotted content keeps its own accessibility contract. Nav items remain real
links, the theme picker remains a native form with radios and a submit button,
the main slot should use semantic content such as <article class="casca-prose">
for prose pages, and footer content should use real text rather than decorative
CSS-only labels.
Chart-Specific AccessibilityLink to section
Bar ChartsLink to section
<!-- Accessible bar chart pattern -->
<figure class="casca casca-figure" role="img" aria-labelledby="bar-title">
<figcaption>
<div id="bar-title" class="casca-title">Revenue by Quarter</div>
</figcaption>
<table class="casca-data">
<!-- Data table for screen readers -->
</table>
<div class="casca-bar" aria-hidden="true">
<div class="casca-bar-group">
<div class="casca-bar-item">
<div class="casca-bar-value"
data-label="Q1: $50k"
role="img"
aria-label="Quarter 1: $50,000">
</div>
</div>
</div>
</div>
</figure>
Diverging Bars (Cash Flow)Link to section
<!-- Clear positive/negative indication -->
<div class="casca-bar-value"
data-direction="positive"
data-label="+$15k"
aria-label="Income: Positive $15,000">
</div>
<div class="casca-bar-value"
data-direction="negative"
data-label="-$8k"
aria-label="Expenses: Negative $8,000">
</div>
Paginated Legends (CSS-only)Link to section
<!-- Fully keyboard accessible pagination -->
<div class="casca-legend-paginated">
<!-- Hidden but accessible radio buttons -->
<input type="radio"
name="legend-pagination"
id="legend-page-1"
checked
aria-label="View legend page 1">
<!-- Visible navigation -->
<div class="casca-legend-nav" role="tablist">
<label for="legend-page-1" role="tab" aria-selected="true">1</label>
<label for="legend-page-2" role="tab" aria-selected="false">2</label>
</div>
</div>
Keyboard Flow:
- Tab to pagination controls
- Arrow keys navigate between page buttons
- Enter/Space activates page
- Focus remains on navigation
Interactive Charts (Buttons/Links)Link to section
<!-- Chart as button with clear focus -->
<button class="casca casca-figure casca-interactive"
aria-label="View detailed sales report">
<div class="casca-title">Monthly Sales</div>
<div class="casca-bar" aria-hidden="true">
<!-- Visual chart -->
</div>
</button>
<!-- Focus state automatically applied -->
button.casca:focus-visible {
outline: 3px solid var(--casca-color-1);
outline-offset: 3px;
}
All read-only chart typesLink to section
Every read-only chart (bar, line, area, pie/donut, progress/gauge, heatmap,
scatter, waterfall, radar, candlestick) uses the same pattern: the visual is
aria-hidden="true" and a .casca-data table (or equivalent semantic text)
carries the data for assistive tech. None of them rely on color alone: each
slice/series/point also carries a data-label / text label, and dark-mode +
forced-colors paths preserve contrast. When adding a chart, pair the visual with
a data-table alternative. Do not ship the visual as the only data source.
For server-rendered output, the Integration Guide shows the static no-JS recipe (figure +
.casca-datatable → static file) and a security contract: keep inlinestylenumeric (server-computed), and HTML-escape every string in text/quoted attributes, so a label can't become CSS/markup injection.
Axis Labels (.casca-axis)Link to section
The axis-label primitive renders ticks and titles as real DOM text, not CSS
pseudo-content, so they are selectable and exposed to assistive tech. They label
the visual but are not a substitute for the .casca-data table, which remains
the source of truth.
Two placement rules keep the labels accessible:
- The radial band (
data-axis="radial") must be a sibling of thearia-hiddenplot, never a child. Otherwise the spoke labels would be hidden from screen readers along with the plot. - Labels carry meaning as text, not color. They stay legible in dark mode (via
--casca-label-color) and in forced-colors mode (the UA forces a system text color); no color-only encoding.
Controls & Interactions AccessibilityLink to section
Unlike the read-only charts, the controls below are native, interactive HTML
(real <input>, <table>, <details>), so they are keyboard-operable and
screen-reader-announced by default. There is no separate aria-hidden visual.
Everything works with JavaScript disabled; state changes ride native form
controls and a server round-trip.
Slot Grid (.casca-slot-grid)Link to section
- Native radio group inside
<fieldset>/<legend>. Tab moves into the group; arrow keys move the selection; Space/Enter selects, all native. - Visible focus ring mirrored from the (visually-hidden) radio onto its
styled
<label>via:has(.casca-slot-input:focus-visible). - Non-interactive states carry
disabled(the load-bearing invariant):data-state="taken"anddata-state="pending"slots setdisabled, so they are removed from the tab order and announced as unavailable while staying in the accessibility tree. - Meaning is never color-only: taken = dashed border + strikethrough;
pending = solid border + a held (non-strikethrough) look; both also show
cursor: not-allowed.selectedrequires an enabled checked input. - Forced colors: selected →
Highlight/HighlightText; taken →GrayText; pending →Mark/MarkText; focus →Highlight, three visually distinct system-color states.
Data Table (.casca-table)Link to section
- Native
<table>semantics (<caption>,<thead>/<tbody>/<tfoot>, andscopeon header cells). No ARIA scaffolding; navigable with table-aware screen-reader commands by default. - Trend cells are not color-only:
data-trendadds a ▲/▼/– glyph and color, but the author keeps the +/- sign in the text, so direction survives without color and in forced colors (where the color is dropped, glyph + sign remain). - Sort state via
aria-sorton the column header; the visual ↑/↓/↕ glyph is keyed offaria-sort, so it can never disagree with the announced state. (Sorting itself is server-driven. No client reordering without JS.) - Row selection: each
.casca-row-selectcheckbox needs a name viaaria-labelledby(→ the row headerid) oraria-label; the select column header carries a.sr-only"Select" label. Selection is conveyed by the checked checkbox itself (not the row tint alone), and forced colors asserts a systemHighlightfor the selected row.
Analytics Block (.casca-analytics)Link to section
- Trend is a native table. The visible trend uses
.casca-analytics-trend-tablewith a real<caption>, row headers, numeric count cells, and visible day status text such assealedorstill tallying. The CSS bar is presentational and carriesaria-hidden="true". - Every state has a
.casca-datasummary. The rootaria-describedbypoints to this summary. It names the state and includes the relevant values or explicitly says no page-view numbers are available. - Integrity fail withholds numbers. In
data-casca-analytics-state="integrity-fail", no unverified numbers are rendered in visible text or.casca-data. The screen-reader summary says numbers are withheld, names the plain-language cause, and precedes the verification link in reading order. - No live region baseline. Analytics output is static server-rendered HTML
by default. Do not add
role="alert"oraria-liveto the block. The opt-in 000084 live layer (dist/casca-live.js) inherits this contract: the script never addsaria-live, never addsrole="alert", never moves focus, never setstabindex. Element identity for the section, the trend table, and the verify link is preserved across every state transition. The script never assigns.innerHTML, never callsNode.replaceWith()/Node.replaceChildren(), and never assignsElement.outerHTML. - Opt-in polite announcement (000084). A host MAY add
aria-live="polite"on a single<p class="casca-analytics-status">node inside the block. If it does, the script's text swap on that node fires a polite screen-reader announcement when the value changes. The script never adds this attribute itself and never setsaria-live="assertive". Default is no announcement. - Hidden scaffolding (000084). The static render emits hidden carriers
for the per-state notice / value scaffold / trend rows the block may
transition into. Carriers use both the
hiddenHTML attribute AND thedisplay: nonegating rules added tosrc/charts/analytics.css. Both mechanisms remove the carrier from the accessibility tree on every current browser; no extra tab stops are introduced and no extra content is announced. The trend table always emits up to seven<tr>rows; inactive rows carrydata-casca-analytics-row-active="false"andhidden. The §9 scoped CSS rule hides them. Assistive tech sees only the active rows. - Keyboard path. The verify link is the only required analytics tab stop and has a 2.75rem minimum block size. Trend rows and registers are read in source order and are not focusable.
Range / Value Slider (.casca-range)Link to section
- Native
<input type="range">, keyboard operable (arrow keys, Home/End, Page Up/Down), and it announces its name, min/max, and current value. Name via an associated<label for>. - Visible focus ring asserted in the accent color (native range focus is inconsistent across engines).
- The thumb position conveys the value independent of color (the
accent-colorbrand is decorative). No live numeric readout (that needs JS). The value submits with the form and the server echoes it; the static.casca-range-scalemin/mid/max labels orient the user meanwhile. :disabledis dimmed +not-allowedand natively blocks interaction.
KPI / Stat Card (.casca-stat)Link to section
- Plain text in reading order (label → value → delta → caption), so a screen reader announces the card as a sentence ("Monthly revenue, $48.2k, up 12.4%, vs last month"). Keep the label before the value so context precedes the number.
- Delta is not color-only: ▲/▼/– glyph + the +/- sign in the text carry direction; color is supplementary and is dropped (glyph + sign kept) in forced colors.
No-JS Interaction Primitives (@layer casca.interactions)Link to section
Toggle, switch, disclosure, and filter are driven by native checkboxes, radios,
and <details>, all keyboard-operable and announced natively.
- Graceful degradation without
:has(): every interaction is gated with@supports selector(:has(*)), and the fallback keeps content visible and usable. A series toggle without:has()simply shows all series; a filter shows all rows; the controls still toggle as native inputs. - Series toggle off-state is not color-only: the chip uses opacity +
line-throughalongside the greyed swatch, and the native checkbox conveys the checked state regardless. - Switch (segmented view swap): a real radio group (arrow-key navigable);
the active segment also re-asserts
Highlight/HighlightTextin forced colors so the selection is visible when author backgrounds are stripped. - Disclosure: a styled native
<details>/<summary>with built-in keyboard toggle and expanded-state announcement, no:has()needed. - Segment (binary slide toggle): a native checkbox, visually hidden but focusable and operated with Space; the focus ring is mirrored onto the track. The two end labels (e.g. Core / Extended) name the states, and the active one is emphasised, so the choice never rides on knob position alone. In forced colors the track gets a system border and a filled knob so the position reads.
- Reduced motion: the interaction chips' transitions are neutralized under
prefers-reduced-motion: reduce. - Theme picker:
.casca-theme-picker[data-casca-theme-picker]is a native GET form with a fieldset, legend, radio labels, and an Apply button. The radio group previews the current page through:has()when supported. The Apply button submitscasca-themeandreturn_toto the request-time persistence endpoint without JavaScript.casca devandcasca previewserve that endpoint when a theme manifest is configured. After the redirect, browsers load the target page normally; no JavaScript focus restoration is added. Screen readers encounter the same form with the persisted radio checked.
Layout Shell Accessibility (casca-extended-layout)Link to section
The shell primitives (card, card-grid, toolbar, anchor-nav) are structural and add no ARIA scaffolding of their own. They rely on correct host markup:
- Card: use a real heading element for
.casca-card-title(e.g.<h3>) so the document outline stays correct; the card is an ordinary container, so a screen reader reads its content in source order. Forced colors re-assert the frame border withCanvasText. - Card grid / toolbar: pure layout (CSS grid / flex). Keep controls in their natural source/focus order; the toolbar imposes no tab-order tricks.
- Tabs (
.casca-switch[data-variant="tabs"]): identical accessibility to the switch, a native arrow-key-navigable radio group with a focus ring mirrored onto the active tab; the underline is re-asserted in forced colors. - Anchor nav: a
<nav>with anaria-label("On this page"); the active item carriesaria-current, which is both the styled and the screen-reader signal. It degrades to a plain, fully usable link list with no:has()or JS.
Testing ChecklistLink to section
Automated TestingLink to section
- Run axe DevTools or WAVE on pages with charts
- Validate HTML with W3C validator
- Check color contrast with contrast analyzer
- Test with browser zoom (200%, 400%)
Manual TestingLink to section
Keyboard Navigation:
- Can Tab through all interactive elements
- Focus indicators are visible
- Can activate all controls with Enter/Space
- No keyboard traps
- Theme picker radios are arrow-navigable and the Apply button is reachable and announced as a submit button
- Slot grid: arrow keys move selection; taken/pending slots are skipped (disabled)
- Range slider: arrow / Home / End change the value; it submits with the form
- Table row checkboxes toggle with Space; switch segments are arrow-navigable
- Interactions degrade gracefully without
:has()(content stays visible)
Screen Readers:
- NVDA: Charts announced with titles
- VoiceOver: Data tables read correctly
- JAWS: Legend items accessible
- TalkBack: Touch exploration works
Visual:
- Text readable at 200% zoom
- Charts usable in high contrast mode
- Color blind simulation (red-green, blue-yellow)
- Dark mode maintains contrast
Motion:
- Animations disabled with prefers-reduced-motion
- No autoplay animations
- No flashing content (seizure risk)
Common Accessibility PatternsLink to section
1. Descriptive TitlesLink to section
<!-- Good: Specific, informative -->
<div class="casca-title">Monthly Revenue: January - June 2025</div>
<!-- Bad: Vague, uninformative -->
<div class="casca-title">Chart</div>
2. Data Table StructureLink to section
<!-- Good: Clear headers and scope -->
<table class="casca-data">
<caption>Quarterly Revenue Breakdown</caption>
<thead>
<tr>
<th scope="col">Quarter</th>
<th scope="col">Revenue</th>
<th scope="col">Growth</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Q1 2025</th>
<td>$150,000</td>
<td>+15%</td>
</tr>
</tbody>
</table>
3. Error StatesLink to section
<!-- Accessible error indication -->
<div class="casca casca-figure" role="alert">
<div class="casca-chip" data-variant="error">
⚠ Data unavailable
</div>
</div>
4. Loading StatesLink to section
<!-- Accessible loading indicator -->
<div class="casca casca-figure" aria-busy="true" aria-live="polite">
<div class="casca-title">Loading chart data...</div>
<div class="casca-progress" data-indeterminate>
<div class="casca-progress-bar"></div>
</div>
</div>
ResourcesLink to section
Server-Rendered AccessibilityLink to section
Go TemplatesLink to section
Go Template Pattern:
type ChartData struct {
ID string
Title string
Description string // For aria-describedby
DataTable template.HTML
}
{{define "accessible-chart"}}
<figure class="casca casca-figure"
role="img"
aria-labelledby="chart-{{.ID}}-title"
{{if .Description}}aria-describedby="chart-{{.ID}}-desc"{{end}}>
<figcaption>
<div id="chart-{{.ID}}-title" class="casca-title">{{.Title}}</div>
{{if .Description}}
<div id="chart-{{.ID}}-desc" class="casca-subtitle">{{.Description}}</div>
{{end}}
</figcaption>
{{{.DataTable}}}
<div class="casca-bar" aria-hidden="true">
<!-- Visual chart -->
</div>
</figure>
{{end}}
htmx Fragment SwapsLink to section
Maintain accessibility across updates:
<!-- Announce updates to screen readers -->
<div id="chart-container"
hx-get="/api/charts/sales"
hx-swap="innerHTML"
hx-on::after-swap="announceUpdate()"
role="region"
aria-live="polite">
<!-- Chart content -->
</div>
<script>
function announceUpdate() {
// Optional JS enhancement for announcements
const sr = document.createElement('div');
sr.setAttribute('role', 'status');
sr.setAttribute('aria-live', 'polite');
sr.textContent = 'Chart updated with new data';
document.body.appendChild(sr);
setTimeout(() => sr.remove(), 1000);
}
</script>
Note: Above JS is progressive enhancement. Chart remains fully accessible without it.
SupportLink to section
For accessibility issues or questions:
- Open issue: https://codeberg.org/skell/casca/issues
- Tag with
accessibilitylabel - Include browser/assistive tech details