modular-ds-utilities
Skill3.9k starsChanged 3 days agoMITView on GitHub
Use when: extracting, reusing, or naming hooks, state management, or other component-agnostic behavior in Primer React, or when a component's behavior hook wraps a native element with built-in behavior such as `<dialog>`. Covers the compound behavior-hook pattern (prop-getters), the controlled component contract for wrapping native elements, the difference between a component's own hook and a generic reusable utility, naming conventions, and where utilities live in the package.
We split this skill into the rules it gives an agent
1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 125
All three are public from @primer/react/experimental with no hookDocs.json, which is below the bar above on both counts.
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Modular DS — Utilities
Utilities are hooks, functions, behaviors, and other reusable logic used to build components. Primer provides utility hooks for established patterns so teams can build accessible experiences on solid foundations — examples include useMergedRefs, useOnEscapePress, and useResponsiveValue, or lower-level packages such as @primer/behaviors.
Utilities are not a layer or a wrapper in a stack — they're shared helpers that a component's own behavior hook, or a consumer directly, composes as needed.
Two kinds of hooks — don't conflate them
A component's own compound behavior hook (use<Component> — e.g. useDialog, useSelectPanel) owns that component's behavior and ARIA, and returns prop-getter functions that consumers spread onto their own elements (getDialogProps(), getTitleProps(), etc.). It:
- Takes an options object with the component's behavioral configuration.
- Handles all ARIA wiring internally (generating IDs, cross-referencing
aria-labelledby/aria-describedby). - Manages lifecycle (open/close, focus management, scroll lock, etc.).
- Fires a dev-mode warning if required accessibility attributes are missing (see
modular-ds-accessibility-contract). - Passes through
aria-labelwhen provided, for cases without a visible title.
Shape of a compound hook returning prop-getters, consumed by a base component:
function useDialog(options: {
open: boolean
onClose: (reason: 'escape' | 'close-button') => void
role?: 'dialog' | 'alertdialog'
'aria-label'?: string
}) {
const titleId = useId()
const [hasTitle, setHasTitle] = useState(false)
// A title part registers itself on mount, so the root knows whether
// `aria-labelledby` has anything real to point at.
const registerTitle = useCallback((node: HTMLElement | null) => setHasTitle(node !== null), [])
// Note this is a hydration difference: the server renders `aria-label` and the client
// swaps to `aria-labelledby` once the title commits. Both are valid names, so nothing
// is ever unnamed — but see `contributor-docs/style.md` on server-side rendering if the
// component needs its markup stable across hydration.
return {
getRootProps: () => ({
role: options.role ?? 'dialog',
'aria-modal': true,
// Only ever return keys you have a value for — see "Getters must not return keys
// they have no value for" below. `aria-labelledby` is gated on a title actually
// being registered: it outranks `aria-label` in the accessible name computation,
// so returning it unconditionally would point the name at an element that never
// rendered *and* suppress the `aria-label` fallback.
...(hasTitle
? {'aria-labelledby': titleId}
: // Truthiness, not `!== undefined`: `aria-label=""` type-checks and renders an
// unnamed dialog, so an `undefined` check would let an empty name through.
options['aria-label'] && {'aria-label': options['aria-label']}),
}),
getTitleProps: () => ({id: titleId, ref: registerTitle}),
getCloseProps: (userProps: {onClick?: React.MouseEventHandler} = {}) => ({
...userProps,
onClick: composeEventHandlers(userProps.onClick, () => options.onClose('close-button')),
}),
}
}
// The root calls the hook once and publishes the getters via context. Hook options are
// destructured explicitly so everything else still reaches the DOM.
function DialogRoot({children, open, onClose, role, 'aria-label': ariaLabel, ...rest}: DialogRootProps) {
const dialog = useDialog({open, onClose, role, 'aria-label': ariaLabel})
return (
<DialogContext.Provider value={dialog}>
<div {...rest} {...dialog.getRootProps()}>
{children}
</div>
</DialogContext.Provider>
)
}
// A merge-signature getter changes what's in `rest`, not the spread order: destructure
// the prop out and hand it to the getter. Leaving `onClick` in `rest` would let the
// getter's own handler overwrite it, which is the clobber this whole section exists
// to prevent.
function DialogCloseButton({onClick, ...rest}: DialogCloseButtonProps) {
const {getCloseProps} = useDialogContext()
return <button {...rest} {...getCloseProps({onClick})} />
}
// Descendant parts read those getters — they never call useDialog() again.
// `getTitleProps` returns a ref, and getters spread last, so this part has to merge
// refs rather than let the getter's ref win — see "Spread prop-getters last" below.
const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>((props, ref) => {
const {getTitleProps} = useDialogContext()
const {ref: titleRef, ...titleProps} = getTitleProps()
const mergedRef = useMergedRefs(ref, titleRef)
return <h2 {...props} {...titleProps} ref={mergedRef} />
})Spread prop-getters last, everywhere. A getter only returns the attributes it owns, so consumer rest props still reach the DOM — className, data-testid and the like are never in the getter's return value and pass straight through, satisfying contributor-docs/style.md's rule about applying rest parameters to the root element. Putting the getter last additionally means a consumer can't silently overwrite generated ARIA: no stray role, aria-modal, aria-labelledby or id clobbering the wiring and leaving the component with an accessible name pointing at an element that doesn't exist.
There's one rule rather than two on purpose — an agent shouldn't have to classify a component as "root" or "part" before it can pick a spread order.
A merge-signature getter changes what's in rest, not the spread order. Where a getter takes the consumer's value as an argument, destructure that prop out and pass it in — {...rest} {...getCloseProps({onClick})}, not {...rest} {...getCloseProps()} with onClick still sitting in rest. A merge getter always returns the key it merges, so leaving the consumer's handler in rest would let the getter's own handler overwrite theirs, which is the exact failure the spread-last rule exists to prevent. The getter still goes last; what changes is that the merged prop reaches the element by one route instead of two, as DialogCloseButton does above.
A getter that returns a ref needs merging too. getTitleProps above returns one, and under React 19 ref is an ordinary prop — so spreading the getter last would silently drop a consumer's ref exactly as it would any other prop. Use forwardRef and useMergedRefs, as DialogTitle does.
Getters must not return keys they have no value for. This is the other half of spreading last, and omitting it turns that rule into an accessibility bug. A key present with the value undefined still overwrites — {...{'aria-labelledby': 'heading'}, ...{'aria-labelledby': undefined}} leaves the key present and valueless, and React then omits the attribute entirely. So a getter that returns every optional attribute unconditionally will delete whatever the consumer put on the element, silently. Build optional attributes conditionally so a getter only ever owns attributes it actually sets:
...(ariaLabel !== undefined && {'aria-label': ariaLabel}),This matters most on a public hook, where the consumer owns the element and a dev-mode warning that inspects hook options rather than the DOM will not catch it.
Where a consumer legitimately needs to influence a generated value, make it a hook option (role and aria-label above), not something they override on the element. Where a consumer needs to add to a getter-owned value rather than replace it — most commonly an onClick alongside the hook's own handler — give that getter a merge signature that composes the two, as getCloseProps does. There's no shared composeEventHandlers utility in the package today; experimental/Tabs/Tabs.tsx defines a local one. Prefer following that precedent with your own local copy. Lifting it into packages/react/src/hooks/ is reasonable now a second component needs it, but it edits a shipped component that your change is not otherwise about — so if you do, call it out in the PR description as a separate concern rather than letting it ride along unmentioned.
Generic, single-purpose, component-agnostic utilities (e.g. useFocusTrap, useFocusZone, useMergedRefs, useOnEscapePress, useResponsiveValue) are not tied to any one component. They live in packages/react/src/hooks/, and a component's compound hook composes them internally as needed. Search packages/react/src/hooks/ for an existing utility before writing a new one — don't duplicate behavior that already exists as a shared hook, and don't assume a plausibly-named hook exists without checking. Note in particular that for state a consumer can control or leave uncontrolled, contributor-docs/style.md calls for useControllableState — reach for it rather than hand-rolling the controlled/uncontrolled split, and rather than the older useProvidedStateOrCreate that appears in more of the existing codebase.
Components that render a collection may need per-item prop-getters keyed by a stable value (getItemProps({value})) rather than the argument-less getters above, which are shaped for singleton components. The discriminator is what the hook owns: reach for per-item getters where it owns per-item state or identity (selection, expansion, active descendant), and don't where it owns only container-level behaviour such as focus management or orientation — there the items are the consumer's own controls and the hook should never address them individually. The key's shape is a decision to surface, not one to assume.
Precedent in the repo: experimental/Tabs
packages/react/src/experimental/Tabs/ is the closest existing implementation of this model and the thing you're most likely to copy. Copy its composition, not its hook shape — it predates the rules above and diverges from them in four ways:
- It has per-part hooks (
useTab,useTabList,useTabPanel) that each calluseTabs()internally, rather than one compound hook whose getters descendants read from context. - Those hooks return plain prop objects (
tabProps), not getter functions. - All three are public from
@primer/react/experimentalwith nohookDocs.json, which is below the bar above on both counts. useTabreturns'aria-disabled': disabled ? true : undefined, and theTabcomponent inTabs.tsxspreads that after{...rest}— a live instance of the clobber described above.Tabisn't exported, so nobody hits this through the public API directly; the problem is that building your ownTabfromuseTabis the documented usage, and the demo component is what you'd copy to do it.
What Tabs does get right, and is worth copying: rest props before generated props, explicit composeEventHandlers for the handlers it needs to merge, and context rather than cloneElement.
Note that Tabs is the only component exported from that directory. Tab, TabList and TabPanel exist in the source as demonstrations of the hooks, not as shipped API.
Controlled component contract
When a compound hook wraps a native element that has its own built-in behavior — <dialog> being the obvious case — the hook must keep the component fully controlled. The native element will otherwise change state behind React's back. These invariants are easy to get subtly wrong and expensive to debug:
- Opening — call
showModal()only whenopen === trueanddialog.open === false. - Closing — call
dialog.close()only whenopen === falseanddialog.open === true. - Escape handling — intercept the native
cancelevent, callpreventDefault(), and fireonClose('escape')rather than letting the native close proceed. - Close guard — listen for the native
closeevent. Ifopenis stilltruebut the element was closed externally (someone calleddialog.close()directly), re-open it. - Backdrop click detection — the
<dialog>backdrop is the element's own area outside its content box, so a backdrop click still targets the dialog. Checke.target === dialogto confirm the click wasn't on a child, then verify the coordinates fall outside the content box rect. - Focus lifecycle — save
document.activeElementbefore opening, and restore focus toreturnFocusRefor the previously-focused element on close.
Together these ensure the component can only be opened or closed through the open prop and onClose callback. Apply the same reasoning to any other native element with built-in state (<details>, <select>, popover-attribute elements): identify what the browser changes on its own, and intercept it.
Naming
Name a component's compound hook use<Component>. Before naming, search the repo for existing hooks with the same name — legacy hooks (e.g. src/hooks/useDialog.ts) may already exist and conflict. If a conflict exists, the new compound hook takes the use<Component> name and the legacy hook should be documented as deprecated (see the deprecations skill).
Public vs internal hooks
Do not expose public hooks for sub-component internals unless the need cannot be met by the presentational parts. That is the bar, and it's deliberately narrower than "a clear consumer need" — a requirement to control layout, ordering or arrangement is met by composition, since the presentational parts already accept arbitrary children in arbitrary structure. That alone never justifies a public hook. A public hook is only necessary when the consumer must own and render the root element itself. Internal hooks are fine when they keep behavior reusable without expanding the public API — pairing a presentational component with a behavior hook doesn't mean every internal behavior hook should become a package export. If in doubt, keep it internal and note in the PR that it can be exported on request: this is the narrower, reversible option in the sense modular-ds-spectrum-model describes, and a public hook is the textbook irreversible one. If a hook is made public, it needs docs metadata and tests matching its API surface, the same as any other public API change — the repo convention for the metadata is a use<Component>.hookDocs.json file alongside the hook (see hooks/useFocusZone.hookDocs.json), and a component's .docs.json does not cover it. Public hooks are also bound by the getter rules above, which a consumer now depends on directly.
Where utilities live
- Component-specific compound hooks: alongside their component, e.g.
packages/react/src/experimental/<Component>/use<Component>.ts, shipping from the same entry point as that component's parts. Where a component has base parts, the hook goes with them; where it has none, it ships with the presentational parts. If base parts later move to a dedicated directory, the hook moves with them (see below). - Generic, component-agnostic utilities:
packages/react/src/hooks/, never inside one component's own directory.
Where base components and compound hooks ultimately live — the directory, the naming, and whether they get a dedicated export subpath at all — is an open question, tracked in the base components pitch. Don't pre-empt it. packages/react/package.json currently exposes ., ./experimental, ./deprecated, ./next and ./test-helpers, plus two generated JSON subpaths. Note that this is a statement about subpaths, not directories: packages/react/src/hooks/ does exist, and several of its hooks are already published from the root entry point.
Adding a new export subpath is a package-level decision, not part of building a component — surface it and stop. Do not add an exports entry to packages/react/package.json, a rolldown entry point, or an exports-snapshot update as part of a component change. Until a subpath exists, ship base components and compound hooks under packages/react/src/experimental/<Component>/ alongside the presentational parts, and note in the PR that they'll move once the entry point lands.
Presentational and config components currently live under packages/react/src/experimental/<Component>/ until they graduate to stable — check where the component's area already sits before creating new directories.
Trending
More talk lately.about the whole repository
Who's talking about primer/react
- vercel/next.js“Turbopack Sass loader does not resolve imports correctly”GitHub · Jan 1, 2024 · 7 reactions
- mui/mui-design-kits“[figma] Support Figma Code Connect ”GitHub · Jun 28, 2024 · 5 reactions
- filipedeschamps/tabnews.com.br“feat(primer/react): update version”GitHub · Dec 20, 2023 · 2 reactions
- github/docs“Fix theme mismatch”GitHub · May 18, 2023 · 2 reactions
- primer/view_components“Component status page”GitHub · Nov 29, 2021 · 2 reactions
The biggest 5 of 36 mentions linking to the repository that our crawl found. Each line is their own headline.
See all 36
Talked about on GitHub (36 times).
On GitHub · 36
- Jan 1, 2024Turbopack Sass loader does not resolve imports correctly — vercel/next.js7 reactions · 11 comments
- Jun 28, 2024[figma] Support Figma Code Connect — mui/mui-design-kits5 reactions · 5 comments
- Dec 20, 2023feat(primer/react): update version — filipedeschamps/tabnews.com.br2 reactions · 10 comments
- May 18, 2023Fix theme mismatch — github/docs2 reactions · 13 comments
- Nov 29, 2021Component status page — primer/view_components2 reactions · 7 comments
31 more on GitHub
- Mar 9, 2026Bump the app group with 8 updates — CRRU-UK/photo-id-app1 reaction · 2 comments
- Feb 12, 2026Add `AGENTS.md` — carbon-design-system/carbon1 reaction · 1 comment
- Feb 2, 2026Bump the app group with 13 updates — CRRU-UK/photo-id-app1 reaction · 2 comments
- Nov 10, 2025deps: bump @primer/react from 37.31.0 to 38.1.0 — npm/documentation1 reaction · 4 comments
- Mar 3, 2025Update release_conductor.yml — primer/.github1 reaction
- Oct 17, 2024Docs: add a11y guidance regarding required fields — primer/design1 reaction · 2 comments
- Oct 15, 2024Fix bug where checkboxes were broken with prefers-reduced-motion — primer/view_components1 reaction · 1 comment
- Aug 30, 2024Atualiza as propriedades do componente `Tooltip` para serem compatíveis com a nova versão (`Tooltip v2`) — filipedeschamps/tabnews.com.br1 reaction · 1 comment
- Mar 28, 2024Tooltip: Update the threshold value to match with the offset — primer/view_components1 reaction · 1 comment
- Feb 28, 2023Show source code in React component examples — primer/design1 reaction
- Dec 13, 2022Add a treatment for consistent appearance of content on focus — primer/design1 reaction · 1 comment
- Nov 9, 2022Using playwright to generate snapshots — primer/view_components1 reaction · 2 comments
- Oct 19, 2022Focus zone calls error in vitest + jsdom — primer/behaviors1 reaction · 2 comments
- Oct 11, 2022Atualiza todas as dependências possíveis — filipedeschamps/tabnews.com.br1 reaction · 2 comments
- Aug 19, 2022Add a CommonJS build to the package — github/combobox-nav1 reaction · 6 comments
- Feb 16, 2022ModuleNotFoundError: Could not find module in path "" relative to "" — codesandbox/codesandbox-client1 reaction · 9 comments
- Oct 29, 2026Create: Timeline in Figma — deboramoratalla-lab/ds-lab0 reactions
- Oct 26, 2026🐛 cheatcode_install registers an MCP server with its repo URL as the command and reports success — trustmybot/plugin0 reactions
- Oct 19, 2026Add named capture modes with mode-keyed baselines (bet) — ravidsrk/frontguard0 reactions
- Oct 15, 2026Rule proposal: Enforce consistency between documented jsDoc default and actual default values — typescript-eslint/typescript-eslint0 reactions · 2 comments
- Sep 26, 2026Refatorações e ajustes finais da Milestone 3 — jeancarlier/clone-tabnews0 reactions
- Jun 24, 2026This project is not maintained anymore 😢 — github/command0 reactions · 1 comment
- Jun 11, 2026WS2 · Regenerate primer-react through the corrected registry-scoped meta-skill (HITL) — vercel-labs/ds-skill-extraction-workshop0 reactions · 3 comments
- Feb 6, 2026Upgrade React to 19, additional major dependency updates (Primer, TanStack, tRPC) — github-community-projects/private-mirrors0 reactions · 3 comments
- Nov 21, 2025User interface — panodata/suraya0 reactions · 4 comments
- Apr 11, 2025✨ 이슈 상세 페이지 UI 개선 — Seono-Na/demo-issue-api-playground0 reactions
- Mar 21, 2025IconButton should include Tooltip by default — benbalter/alt-text-generator-action0 reactions
- Mar 21, 2024Improve SSR by removing Webpack dependency — github-community-projects/org-metrics-dashboard0 reactions
- Jan 11, 2024Other relevant component libraries/frameworks — crate/crate-ui-components0 reactions
- Jan 5, 2024Add a pull request template — deven-org/boiler0 reactions
- Sep 23, 2022Element should not auto-register itself unless a browser context is found — github/markdown-toolbar-element0 reactions · 8 comments
In this repository17 other files it gives its agents.
- .github/copilot-instructions.mdCopilot instructions
- agentic-workflowsSkill
- changesetsSkill
- component-testingSkill
- create-adr-migratorSkill
- deprecationsSkill
- feature-flagsSkill
- modular-ds-accessibility-contractSkill
- modular-ds-base-componentsSkill
- modular-ds-config-componentsSkill
- modular-ds-presentational-componentsSkill
- modular-ds-spectrum-modelSkill
- slotsSkill
- storybookSkill
- style-guideSkill
- turborepoSkill
- write-skillSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

