diff --git a/.changeset/visually-hidden-fixed-position.md b/.changeset/visually-hidden-fixed-position.md
new file mode 100644
index 00000000..4d6429dc
--- /dev/null
+++ b/.changeset/visually-hidden-fixed-position.md
@@ -0,0 +1,5 @@
+---
+'@human-kit/ui': patch
+---
+
+Fix: the screen reader live regions of `Button`, `Dropzone` and `TimePicker` wheel columns now use `position: fixed`, not `position: absolute`. An absolute live region inside a scroll container that is not positioned is laid out against an ancestor outside the container. Chrome then puts the sticky content near it, such as a page header, in its own compositing layer, and the text of that content loses subpixel anti-aliasing. All the live regions of the library now use one shared style.
diff --git a/packages/ui/src/lib/autocomplete/status/autocomplete-status.svelte b/packages/ui/src/lib/autocomplete/status/autocomplete-status.svelte
index 06678d15..b4752e55 100644
--- a/packages/ui/src/lib/autocomplete/status/autocomplete-status.svelte
+++ b/packages/ui/src/lib/autocomplete/status/autocomplete-status.svelte
@@ -1,5 +1,6 @@
diff --git a/packages/ui/src/lib/button/root/button-root.svelte b/packages/ui/src/lib/button/root/button-root.svelte
index 43cb3a0f..f9358069 100644
--- a/packages/ui/src/lib/button/root/button-root.svelte
+++ b/packages/ui/src/lib/button/root/button-root.svelte
@@ -7,6 +7,7 @@
} from '../../primitives/input-modality';
import { watchFocusVisible } from '../../primitives/focus-visible.svelte';
import { cn } from '../../utils/cn';
+ import { visuallyHiddenStyle } from '../../primitives/visually-hidden';
export type ButtonRenderState = {
hovered: boolean;
@@ -429,13 +430,14 @@
{pendingAnnouncement}{pendingAnnouncement}
diff --git a/packages/ui/src/lib/button/root/button.test.ts b/packages/ui/src/lib/button/root/button.test.ts
index c400776f..bd9b261e 100644
--- a/packages/ui/src/lib/button/root/button.test.ts
+++ b/packages/ui/src/lib/button/root/button.test.ts
@@ -170,6 +170,16 @@ describe('Button.Root', () => {
expect(buttonElement.textContent).not.toContain(', pending');
});
+ it('keeps the live region fixed, so it never escapes a scroll container', () => {
+ // An absolute live region inside an unpositioned scroller made Chrome promote the sticky
+ // content around it to its own layer, which dropped subpixel anti-aliasing from its text.
+ render(ButtonTest, { pending: true });
+ const status = document.querySelector('[data-button-live-region="true"]');
+
+ expect(status).not.toBeNull();
+ expect(getComputedStyle(status as HTMLElement).position).toBe('fixed');
+ });
+
it('updates to pending dynamically and suppresses later activation', async () => {
render(ButtonTest, { type: 'submit' });
const button = document.querySelector('[data-button-root="true"]');
diff --git a/packages/ui/src/lib/clock/wheel-column/clock-wheel-column.svelte b/packages/ui/src/lib/clock/wheel-column/clock-wheel-column.svelte
index 34e11f3d..69cf9953 100644
--- a/packages/ui/src/lib/clock/wheel-column/clock-wheel-column.svelte
+++ b/packages/ui/src/lib/clock/wheel-column/clock-wheel-column.svelte
@@ -2,6 +2,7 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { resolveLocalizedString } from '../../internal/localized-strings';
+ import { visuallyHiddenStyle } from '../../primitives/visually-hidden';
import { useClockContext, type ClockEditableSegmentType } from '../root/context';
import TimePickerWheelItem from '../wheel-item/clock-wheel-item.svelte';
import { useWheelScroll, type WheelScrollBehavior } from '../hooks/use-wheel-scroll.svelte';
@@ -506,18 +507,13 @@
>
- {valueText}
+ {valueText}
{#each options as option, index (option.value)}
{#if children}
{@render children(option, selectedValue === option.value)}
diff --git a/packages/ui/src/lib/combobox/status/combobox-status.svelte b/packages/ui/src/lib/combobox/status/combobox-status.svelte
index 79cf7ff6..dad79d4c 100644
--- a/packages/ui/src/lib/combobox/status/combobox-status.svelte
+++ b/packages/ui/src/lib/combobox/status/combobox-status.svelte
@@ -3,6 +3,7 @@
import { resolveLocalizedString } from '../../internal/localized-strings';
import { useLocaleContextOptional } from '../../locale-provider/context';
import { useComboBoxContext } from '../root/context';
+ import { visuallyHiddenStyle } from '../../primitives/visually-hidden';
/**
* ComboBox.Status - Visually-hidden live region that announces the number of
@@ -25,9 +26,6 @@
const ctx = useComboBoxContext();
- const visuallyHiddenStyle =
- 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
-
const defaultMessage = $derived.by(() => {
const count = ctx.visibleCount;
if (count === 0) return resolveLocalizedString($localeStore, 'combobox.noResults');
diff --git a/packages/ui/src/lib/dropzone/dropzone.svelte b/packages/ui/src/lib/dropzone/dropzone.svelte
index 5ff24b27..397cb625 100644
--- a/packages/ui/src/lib/dropzone/dropzone.svelte
+++ b/packages/ui/src/lib/dropzone/dropzone.svelte
@@ -2,6 +2,7 @@
import { untrack, type Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
import { shouldShowFocusVisible, trackInteractionModality } from '../primitives/input-modality';
+ import { visuallyHiddenStyle } from '../primitives/visually-hidden';
import type { ClassValue } from '../utils/cn';
import { composeEventHandlers } from '../utils/compose-event-handlers';
@@ -315,10 +316,6 @@
onchange={handleInputChange}
/>
-{announcement}
diff --git a/packages/ui/src/lib/primitives/visually-hidden.test.ts b/packages/ui/src/lib/primitives/visually-hidden.test.ts
new file mode 100644
index 00000000..db7b9f16
--- /dev/null
+++ b/packages/ui/src/lib/primitives/visually-hidden.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { visuallyHiddenStyle } from './visually-hidden';
+
+function styleOf(css: string): CSSStyleDeclaration {
+ const element = document.createElement('span');
+ element.setAttribute('style', css);
+ return element.style;
+}
+
+describe('visuallyHiddenStyle', () => {
+ it('lays the element out against the viewport, so it never escapes a scroll container', () => {
+ const style = styleOf(visuallyHiddenStyle);
+
+ expect(style.position).toBe('fixed');
+ expect(style.top).toBe('0px');
+ expect(style.left).toBe('0px');
+ });
+
+ it('hides the element visually and keeps it in the accessibility tree', () => {
+ const style = styleOf(visuallyHiddenStyle);
+
+ expect(style.width).toBe('1px');
+ expect(style.height).toBe('1px');
+ expect(style.overflow).toBe('hidden');
+ expect(style.clip).toBe('rect(0px, 0px, 0px, 0px)');
+ expect(style.display).toBe('');
+ expect(style.visibility).toBe('');
+ });
+});
diff --git a/packages/ui/src/lib/primitives/visually-hidden.ts b/packages/ui/src/lib/primitives/visually-hidden.ts
new file mode 100644
index 00000000..03a0db06
--- /dev/null
+++ b/packages/ui/src/lib/primitives/visually-hidden.ts
@@ -0,0 +1,16 @@
+/**
+ * Inline style for content that only assistive technology reads, such as a live region.
+ * Inline, because the library is headless and must not depend on an `sr-only` class.
+ *
+ * `position: fixed`, not `absolute`: an absolutely positioned element inside a scroll container
+ * that is not itself positioned is laid out against an ancestor outside the scroller. While the
+ * container scrolls, Chrome then promotes the content around it (a sticky header, say) to its own
+ * compositing layer to keep the element in place, and that layer drops subpixel anti-aliasing
+ * from its text. A fixed element is laid out against the viewport and never escapes a scroller.
+ *
+ * Native form controls that stand in for a custom one (a hidden `` or `