Skip to content

bug(ui): V2 AnimatedNumber exposes animation digits and reversed reading order to screen readers #54653

Closed

Description

@aimansour

Description

V2's AnimatedNumber still exposes animation cells to assistive technology and gives multi-digit numbers the wrong reading order.

With the unmodified production component and CSS rendered in Chromium / Solid 1.9.15:

  • Value 12 looks correct in LTR, but the accessibility snapshot contains text 2, then 1.
  • During a 5 to 6 update, the accessibility snapshot exposes 0123456789 three times instead of the actual count. The existing motion-duration CSS variable was increased only to capture this transient state.

The component reverses digits in DOM order and renders 30 cells per animated strip, without aria-hidden on the visual digits. The parent span's aria-label does not replace those children in the observed accessibility tree: production component.

CSS hides the strip at rest, but exposes it while animating; visual row-reverse does not repair accessibility reading order: production CSS. A current consumer is ToolCountLabel.

OpenCode version

2.0.26 source build, latest v2 commit 4617210822bdbb31e5749751afbfb4584ccedac8 (audited 2026-10-12). Both component and CSS are unchanged at current dev commit 707de5832656e89cc0f04436aa1cd5f4f91c5763.

Steps to reproduce

Render <AnimatedNumber value={12} /> with its production CSS, inspect its accessibility tree, then increment a single-digit value during animation.

Expected: one correctly ordered, current numeric value. Expose stable text for that value and hide the visual animation from assistive technology regardless of motion state.

Related reports

V2 continuation of Problem 2 in #41408, closed during V1 migration triage. #43643 concerns visual RTL ordering in the same component. Streaming-content exposure is tracked separately in #54652 and addressed by PR #51084, which does not fix this component.

Activity

  1. opencode-agent commented on Oct 12, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I was able to reproduce this. I rendered the production AnimatedNumber with its CSS in headless Chromium and read the Playwright accessibility snapshot:

    • value={12} is exposed as 2 1.
    • During a 5 → 6 update (with --tool-motion-odometer-ms raised so the snapshot lands mid-animation), it is exposed as 0 1 2 3 4 5 6 7 8 9 three times.

    The component and CSS are the same in v2.0.26 and on the latest dev (707de58), so it still happens there. The script below reproduces it.

    Related: #43643 (wrong visual digit order in RTL locales, caused by the same DOM reversal), #41408 (the v1 report of this problem).

    Reproduction script (repro.ts)
    // Reproduces #54653: AnimatedNumber's accessibility tree (reading order + animation cells).
    // Usage: REPO=/workspace/repo bun repro.ts   (needs solid-js, esbuild, esbuild-plugin-solid, playwright + chromium)
    import { build } from "esbuild"
    import { solidPlugin } from "esbuild-plugin-solid"
    import { chromium } from "playwright"
    import { readFileSync, writeFileSync, mkdirSync } from "fs"
    
    const repo = process.env.REPO ?? "/workspace/repo"
    const dir = "/tmp/opencode/an"
    mkdirSync(dir, { recursive: true })
    writeFileSync(`${dir}/animated-number.tsx`, readFileSync(`${repo}/packages/ui/src/components/animated-number.tsx`))
    writeFileSync(`${dir}/entry.tsx`, `
    import { render } from "solid-js/web"
    import { createSignal } from "solid-js"
    import { AnimatedNumber } from "./animated-number"
    const [a] = createSignal(12)
    const [b, setB] = createSignal(5)
    ;(window as any).bump = () => setB(6)
    render(() => <div><p id="a"><AnimatedNumber value={a()} /></p><p id="b"><AnimatedNumber value={b()} /></p></div>, document.getElementById("root")!)
    `)
    const out = await build({ entryPoints: [`${dir}/entry.tsx`], bundle: true, write: false, format: "esm",
      nodePaths: [`${import.meta.dir}/node_modules`], plugins: [solidPlugin()], conditions: ["browser"] })
    const css = readFileSync(`${repo}/packages/ui/src/components/animated-number.css`, "utf8")
    const html = `<!doctype html><html><head><style>${css} :root{--tool-motion-odometer-ms:20000ms}</style></head>
    <body><div id="root"></div><script type="module">${out.outputFiles[0].text}</script></body></html>`
    
    const browser = await chromium.launch()
    const page = await browser.newPage()
    await page.setContent(html)
    await page.waitForSelector("#b [data-component=animated-number]")
    console.log("== value 12, at rest ==\n" + await page.locator("#a").ariaSnapshot())
    console.log("innerText:", JSON.stringify(await page.locator("#a").innerText()))
    console.log("== value 5, at rest ==\n" + await page.locator("#b").ariaSnapshot())
    await page.evaluate(() => (window as any).bump())
    await page.waitForTimeout(300)
    console.log("== 5 -> 6, mid-animation ==\n" + await page.locator("#b").ariaSnapshot())
    await page.screenshot({ path: "/workspace/repro/animating.png" })
    await browser.close()

    Run it: bun repro.ts

  2. argszero commented on Oct 12, 2026

    @argszero
    Contributor

    I'd like to work on this.

    I read the component at packages/ui/src/components/animated-number.tsx and the report matches the code: digits() reverses the label (digits = [...label].reverse()), so the DOM exposes 2 before 1 for 12, and each Digit renders a 30-cell TRACK strip that stays in the accessibility tree whether or not it is animating — data-animating is only a visual hook and aria-label on the root does not replace its children.

    My plan, kept to the a11y contract the issue names:

    • hide the decorative layer from assistive technology — the digit strip and the reversed static digit both live under one wrapper, so a single aria-hidden="true" covers the whole visual tree
    • expose the current value once, in reading order, through the accessible name on the root, so a value is announced as 12 and not as 2 1

    No visual change: the strip stays in the DOM and the CSS keeps driving the odometer, and the reversed DOM order plus row-reverse stays as it is — the fix is about what assistive technology sees, not about what the eye sees.

    I will verify with a component test in the same Storybook harness the rest of the repo's browser tests use (packages/ui/src/**/*.stories.tsx is already picked up by the shared Storybook config): it renders the production component and asserts the accessibility snapshot is the current value in order at rest and mid-animation, and that no animated-number-cell is exposed.

    Opening the PR against dev as soon as today's output cap allows.

  3. added 3 commits that reference this issue on Oct 15, 2026
    4ee175c
    5c7b011
    0441cf2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions