diff --git a/.agents/skills/component-testing/SKILL.md b/.agents/skills/component-testing/SKILL.md
new file mode 100644
index 00000000000..9196a93df86
--- /dev/null
+++ b/.agents/skills/component-testing/SKILL.md
@@ -0,0 +1,138 @@
+---
+name: component-testing
+description: 'Use when authoring, modifying, reviewing, or debugging Primer React component and hook tests in Vitest browser mode. Do not use for Node/SSR tests, standalone Playwright visual or accessibility tests, or component implementation without test changes.'
+---
+
+# Component Testing
+
+Use these conventions for Primer React component and hook tests in Vitest browser
+mode. Test observable behavior and the component's public contract rather than
+its implementation details.
+
+Read [the testing guide](../../../contributor-docs/testing.md) for strategy,
+environment setup, and commands. Keep Node/SSR tests and the separate Playwright
+visual and accessibility suites on the APIs available in their own environments.
+
+## Start with RTL rendering and browser interactions
+
+Use React Testing Library (RTL) for `render`, `rerender`, `unmount`, `renderHook`,
+cleanup, and manual `act` boundaries. Use `page` and `userEvent` from
+`vitest/browser` for browser queries and interactions.
+
+```tsx
+import {render} from '@testing-library/react'
+import {expect, it, vi} from 'vitest'
+import {page, userEvent} from 'vitest/browser'
+import {Button} from '@primer/react'
+
+it('calls onClick', async () => {
+ const onClick = vi.fn()
+ render()
+
+ const button = page.getByRole('button', {name: 'Submit', exact: true})
+ await expect.element(button).toBeVisible()
+ await userEvent.click(button)
+ expect(onClick).toHaveBeenCalledTimes(1)
+})
+```
+
+Await browser interactions directly, without wrapping them in `act`.
+
+## Choose assertions based on when the result should be available
+
+| Contract | Assertion |
+| :---------------------------------------- | :------------------------------------------------------ |
+| Eventual DOM state | `await expect.element(locator).toHaveAttribute(...)` |
+| Eventual computed value | `await expect.poll(() => readCurrentValue()).toBe(...)` |
+| Immediate result or synchronous guarantee | `expect(value).toBe(...)` |
+
+Prefer assertions on observable conditions over fixed sleeps. This lets the test
+continue as soon as the expected state is available.
+
+```tsx
+// Avoid
+await new Promise(resolve => setTimeout(resolve, 100))
+expect(document.getElementById(announcementId)?.textContent).toBe('Loading')
+
+// Prefer
+await expect.poll(() => document.getElementById(announcementId)?.textContent).toBe('Loading')
+```
+
+Read the current value inside the polling callback so each attempt checks the
+latest state.
+
+Use ordinary `expect` when the result must be available immediately, such as
+after synchronous focus redirection, event cancellation, or explicit timer
+advancement. Retrying these assertions can hide a regression.
+
+When testing timed behavior, keep fake timers and advance them explicitly inside
+RTL's `act`. Assert the state at the required boundaries, including before the
+deadline when relevant, so the test verifies when the change occurs rather than
+only that it eventually occurs.
+
+## Use RTL's act for manual React updates
+
+Import `act` from `@testing-library/react`, not `react`. Use it for manual updates
+that can trigger React work, including programmatic focus, timer advancement,
+hook updates, and hydration.
+
+```tsx
+import {act} from '@testing-library/react'
+
+const button = page.getByRole('button', {name: 'Submit', exact: true})
+act(() => {
+ button.element().focus()
+})
+expect(button.element()).toHaveFocus()
+```
+
+RTL scopes the React act environment to the operation. When an act warning
+occurs, check that manual updates use RTL's `act` and browser interactions are
+awaited directly. Keep that scoped behavior rather than changing
+`IS_REACT_ACT_ENVIRONMENT` globally or suppressing the warning.
+
+## Use locators for current DOM state
+
+Prefer role and accessible-name queries. A locator resolves again each time it
+is used, so it must stay unambiguous after interactions. For example, expanding a
+tree can make a partial name match both a parent and its descendants. Use an
+exact accessible name or scope the query to the intended region.
+
+Prefer an exact accessible name or a scoped query over `.first()`. This keeps the
+test tied to the intended element when the DOM changes. Use positional selection
+when position is part of the behavior being tested.
+
+Use `.element()` when the assertion needs a DOM node, such as for computed
+styles, structural relationships, existing snapshots, or node identity. Capture
+the node before removal when checking that a particular node was detached:
+
+```tsx
+const {rerender} = render()
+const originalButton = page.getByRole('button', {name: 'Submit', exact: true}).element()
+
+rerender(<>>)
+expect(originalButton).not.toBeInTheDocument()
+```
+
+## Assert presence and visibility separately
+
+DOM presence and visibility describe different behavior. Assert removal when a node
+must be removed, and visibility when it may remain mounted but hidden.
+Role queries normally exclude inaccessible elements, so a missing role match
+alone doesn't prove that a hidden node was removed.
+
+For accessibility relationships, check the actual ID reference and the referenced
+node's content. A generic text match can select the wrong node, such as a
+spinner's "Loading" text instead of the live-region announcement. Precise DOM
+access is appropriate when testing that relationship.
+
+## Keep intentional low-level event tests
+
+Use browser interactions for user behavior, but keep synthetic events when
+testing disabled-click suppression, event cancellation, or a specific event
+payload. Browser actions wait for actionable elements and don't expose the
+boolean result of `dispatchEvent`.
+
+If a browser action unexpectedly fails because an element cannot be interacted
+with, investigate its visibility, enabled state, and other interaction
+requirements before choosing a synthetic event.