Skill v1.0.0
currentAutomated scan100/100name: sana-canvas-kit-builder description: >- Build custom React components with Canvas Kit factories from @workday/canvas-kit-react/common. Use when creating createComponent, createContainer, createSubcomponent, createModelHook, or createElemPropsHook; designing compound component APIs; or adding shared state/behavior. For styling see /sana-canvas-kit-styling; for tokens see /sana-canvas-kit-tokens; for accessibility see /sana-canvas-kit-accessibility; for avoiding deprecated exports see /sana-canvas-kit-component-selection. metadata: version: "1.0.0"
Canvas Kit Component Builder
Build custom components in consumer apps using Canvas Kit's exported factories. This skill covers the factory pattern — not contributing a component into the canvas-kit repo itself (that workflow uses yarn create-component, Storybook, Cypress, and stricter repo requirements).
Styling → /sana-canvas-kit-styling. Tokens → /sana-canvas-kit-tokens. Accessibility → /sana-canvas-kit-accessibility. Deprecated exports when composing existing components → /sana-canvas-kit-component-selection. Version detection (which factory APIs are available) → /sana-canvas-kit-version. Deciding whether a Canvas component already covers the use case before building your own → /sana-canvas-kit-design-principles.
When to apply
- Building a new reusable component with or without shared state
- Designing a compound component API (
<MyThing.Target>,<MyThing.Content>) - Extracting behavior into reusable elemProps hooks
- Wrapping Canvas Kit primitives into a tighter app-specific API
Factory selection
| Situation | Use | |
|---|---|---|
| Styled element, no behavior, no shared state | createComponent | |
| Root that owns/provides a model | createContainer + createModelHook | |
| Child that consumes the model | createSubcomponent | |
| Reusable prop/behavior logic on an element | createElemPropsHook (+ composeHooks) | |
| State + events (config, guards, callbacks) | createModelHook |
No shared state? Use createComponent — don't add a model.
Shared state across subcomponents? createModelHook → createContainer (root) → createSubcomponent (children).
Minimal compound component
Model
createModelHook gives you should<Event> guards and on<Event> callbacks for free.
// useDisclosureModel.tsimport * as React from 'react';import {createModelHook, useUniqueId} from '@workday/canvas-kit-react/common';export const useDisclosureModel = createModelHook({defaultConfig: {id: undefined as string | undefined,initialVisible: false,},})(config => {const id = useUniqueId(config.id);const [visible, setVisible] = React.useState(config.initialVisible);return {state: {id, visible},events: {show() {setVisible(true);},hide() {setVisible(false);},},};});
Container
// Disclosure.tsximport {createContainer} from '@workday/canvas-kit-react/common';import {useDisclosureModel} from './useDisclosureModel';import {DisclosureTarget} from './DisclosureTarget';import {DisclosureContent} from './DisclosureContent';export interface DisclosureProps {}export const Disclosure = createContainer()({displayName: 'Disclosure',modelHook: useDisclosureModel,subComponents: {Target: DisclosureTarget,Content: DisclosureContent,},})<DisclosureProps>(({children}) => <>{children}</>);
createContainer adds children, model, ref, and as to the prop types automatically. Do not declare them on your Props interface. Subcomponent JSDoc belongs inside the `subComponents` object on the container.
Subcomponent with elemProps hook
// useDisclosureTarget.tsimport {composeHooks, createElemPropsHook} from '@workday/canvas-kit-react/common';import {useDisclosureModel} from './useDisclosureModel';export const useDisclosureTarget = composeHooks(createElemPropsHook(useDisclosureModel)(model => ({type: 'button','aria-expanded': model.state.visible,'aria-controls': model.state.id,onClick() {if (model.state.visible) {model.events.hide();} else {model.events.show();}},})));// DisclosureTarget.tsximport {createSubcomponent} from '@workday/canvas-kit-react/common';import {useDisclosureModel} from './useDisclosureModel';import {useDisclosureTarget} from './useDisclosureTarget';export interface DisclosureTargetProps {}export const DisclosureTarget = createSubcomponent('button')({modelHook: useDisclosureModel,elemPropsHook: useDisclosureTarget,})<DisclosureTargetProps>((elemProps, Element) => <Element {...elemProps} />);
Static subcomponent (no behavior)
import {createComponent} from '@workday/canvas-kit-react/common';import {createStyles, handleCsProp} from '@workday/canvas-kit-styling';import {system} from '@workday/canvas-tokens-web';const headingStyles = createStyles({...system.type.heading.sm,color: system.color.fg.strong,});export const CardHeading = createComponent('h3')({displayName: 'Card.Heading',Component: ({children, ...elemProps}, ref, Element) => (<Element ref={ref} {...handleCsProp(elemProps, headingStyles)}>{children}</Element>),});
Core rules
- Never call `createElemPropsHook` / `composeHooks` inside a render function — hoist to module
scope.
- Merge props correctly — primitives override; callbacks/
style/className/csmerge. Use
mergeProps for hand-rolled props; handleCsProp for cs in reusable styled components (/sana-canvas-kit-styling).
- Don't declare `children`, `model`, `ref`, or `as` on Props — factories add them.
- No default exports in library modules.
- No TS `enum` — use disjoint string unions (
size: 'small' | 'medium' | 'large'). - Prop names never repeat the component name (
type, notbuttonType). - Avoid direction/color-coupled names (
startIcon, notleftIcon— breaks under RTL). - JSDoc every prop — include
@defaulton booleans and enum props. - Import from public subpaths —
@workday/canvas-kit-react/<component>, never the package barrel
or /lib/.
- Define styles at module level —
createStyles/createStencil+cs, not style props
(/sana-canvas-kit-styling).
Model composition
Compose smaller models when behavior is reusable (e.g. useIDModel for id + aria-controls):
export const useDisclosureModel = createModelHook({defaultConfig: {...useIDModel.defaultConfig,initialVisible: false,},})(config => {const idModel = useIDModel(config);const [visible, setVisible] = React.useState(config.initialVisible);return {state: {...idModel.state, visible},events: {...idModel.events,show() { setVisible(true); },hide() { setVisible(false); },},};});
Lists and grids — use the collection system
If the custom component is a list, grid, or set of items with selection, roving focus, or arrow-key navigation, extend @workday/canvas-kit-react/collection instead of hand-rolling selection/useState for the item set:
- Models:
useListModel,useGridModel,useOverflowListModel - Item hooks:
useListItemRegister,useListItemSelect,useListItemRovingFocus(compose via
composeHooks; register last unless overriding a prop register sets)
- Selection managers:
singleSelectionManager,multiSelectionManager
Keep a plain createModelHook (like the disclosure example above) for non-collection state (visibility, ids, form flags). For contributing a collection-based component into the canvas-kit repo, see /sana-canvas-kit-contribute-component.
Accessibility
Wire a11y in createElemPropsHook (e.g. aria-expanded, aria-controls, id from useIDModel). Full patterns — naming, keyboard, popups, forms, live regions, MCP lookup → /sana-canvas-kit-accessibility.
Workflow
- [ ] Pick factory (createComponent vs createContainer + model)- [ ] List/grid/selection/roving focus? → extend collection models; else createModelHook- [ ] Extract elemProps into createElemPropsHook (module scope)- [ ] Style with createStyles/createStencil + cs (/sana-canvas-kit-styling)- [ ] Use system tokens (/sana-canvas-kit-tokens)- [ ] Verify composed Canvas Kit imports are not @deprecated (/sana-canvas-kit-component-selection)- [ ] JSDoc every prop; run a11y checklist (/sana-canvas-kit-accessibility)
App-level wrappers
Compound APIs are verbose by design. Apps often wrap them into tighter interfaces:
export const Expandable = ({targetText, children}: ExpandableProps) => (<Disclosure><Disclosure.Target>{targetText}</Disclosure.Target><Disclosure.Content>{children}</Disclosure.Content></Disclosure>);
This is encouraged when the app knows its context (translations, test ids, analytics).
Anti-patterns
- ❌
createModelHookfor a static styled div - ❌ Hand-rolled selection / roving tabindex for a list or grid — use collection
- ❌
createElemPropsHookinside render - ❌ Manual
React.ContextwhencreateContainer/createSubcomponenthandle it - ❌
forwardRef+ Context by hand — use the factories - ❌ Style props on
Flex/Box(padding="s") - ❌
enumfor prop types - ❌
leftIcon/blueVariantprop names - ❌ Default exports
- ❌ Importing from
@workday/canvas-kit-reactbarrel or/lib/
Decision guide
No shared state, just markup/styles? → createComponentRoot provides state to children? → createContainer + createModelHookList/grid with selection or roving focus?→ extend useListModel / collection item hooksChild reads model from context? → createSubcomponentReusable ARIA/event props for an element?→ createElemPropsHookCombining multiple elemProps hooks? → composeHooks (module scope)Need id refs for a11y? → compose useIDModel into your modelHow to style? → /sana-canvas-kit-stylingWhich token? → /sana-canvas-kit-tokensComposing existing Canvas components? → /sana-canvas-kit-component-selectionAccessibility (labels, keyboard, ARIA)? → /sana-canvas-kit-accessibilityDoes a Canvas component already do this?→ /sana-canvas-kit-design-principles (check before building)
Additional resources
- Full walkthrough:
modules/docs/mdx/CREATING_COMPOUND_COMPONENTS.mdx - API patterns:
modules/docs/mdx/API_PATTERN_GUIDELINES.mdx - Accessibility:
modules/docs/mdx/accessibility/AccessibilityOverview.mdx