diff --git a/packages/dev/s2-docs/pages/s2/custom-components.mdx b/packages/dev/s2-docs/pages/s2/custom-components.mdx new file mode 100644 index 00000000000..250ad90877b --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/custom-components.mdx @@ -0,0 +1,543 @@ +import {Layout} from '../../src/Layout'; +import {InlineAlert, Heading, Content, Link} from '@react-spectrum/s2'; +export default Layout; + +export const section = 'Guides'; +export const tags = ['style', 'macro', 'spectrum', 'custom', 'components', 'react aria']; +export const description = 'Learn how to build custom Spectrum 2 components using React Aria Components and the style macro.'; + +# Creating Custom Components + +React Spectrum includes many components, but there may be times when you want to implement something custom that doesn't already exist in the library. + +This can be achieved by using the **[Spectrum 2 style macro](styling)** with unstyled **[React Aria Components](react-aria:)**. + +React Aria provides the component's behavior and accessibility features, while the `style` macro gives you access to Spectrum 2 design tokens for colors, spacing, typography, and more. + +```tsx +// 1. Import the React Aria Component +import {Button} from 'react-aria-components/Button'; + +// 2. Import the style macro (with type: 'macro') +import {style, focusRing} from '@react-spectrum/s2/style' with {type: 'macro'}; + +// 3. Define the styles +const buttonStyles = style({ + ...focusRing(), + backgroundColor: 'accent', + color: 'neutral', + paddingX: 16, + paddingY: 8, + borderRadius: 'pill', + borderStyle: 'none', + font: 'ui', + transition: 'default' +}); + +function MyButton({children}) { + // 4. Apply the styles to the component's className + return ; +} +``` + +The `style` macro runs at build time and returns a class name string. Because it produces atomic CSS using Spectrum 2 design tokens, your custom components automatically use the Spectrum 2 styles as the rest of the library. + +## Render props + +React Aria Components pass interaction state via [render props](react-aria:styling#render-props). When you set `className` to a function, the component calls it with the current state (hover, press, focus, etc.) and uses the returned string as the class name. The `style` macro is designed to work directly with this pattern. + +When a `style` call includes conditions like `isHovered` or `isPressed`, the macro returns a **function** instead of a static string. This function accepts the render props and resolves the correct classes at runtime. + +```tsx render type="s2" +"use client"; +import {Button} from 'react-aria-components/Button'; +import {style, focusRing} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const buttonStyles = style({ + ...focusRing(), + /*- begin highlight -*/ + backgroundColor: { + default: 'accent', + isHovered: 'accent-700', + isPressed: 'accent-600' + }, + /*- end highlight -*/ + color: 'neutral', + paddingX: 16, + paddingY: 8, + borderRadius: 'pill', + borderStyle: 'none', + font: 'ui', + transition: 'default' +}); + +function MyButton({children}) { + return ; +} + + + Custom Button + +``` + +Because the `style` result already has the correct function signature, you can pass it directly to `className` without a wrapper. React Aria calls it with `{isHovered, isPressed, isFocusVisible, ...}` and the macro resolves the matching classes. + +### Available render props + +Every React Aria component exposes its own set of render props. Here are some of the most common ones: + +- **`isHovered`** – mouse is over the element +- **`isPressed`** – element is being pressed +- **`isFocused`** – element has focus (either via mouse or keyboard) +- **`isFocusVisible`** – element has keyboard focus (useful for focus rings) +- **`isDisabled`** – element is disabled +- **`isSelected`** – element is selected + +When you inline `style()` inside the `className` prop, TypeScript will autocomplete the available conditions for that component. + +Each React Aria component's render props are documented in the **API** section of its documentation page. + +### Custom conditions + +You can also define your own arbitrary conditions that map to component props like `variant`. Pass these alongside the render props when calling the style function. + +```tsx render type="s2" +"use client"; +import {Button} from 'react-aria-components/Button'; +import {style, focusRing} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const buttonStyles = style({ + ...focusRing(), + backgroundColor: { + /*- begin highlight -*/ + variant: { + primary: 'accent', + secondary: 'neutral-subtle' + } + /*- end highlight -*/ + }, + color: 'neutral', + paddingX: 16, + paddingY: 8, + borderRadius: 'pill', + borderStyle: 'none', + font: 'ui', + transition: 'default' +}); + +interface MyButtonProps { + variant: 'primary' | 'secondary'; + children: React.ReactNode; +} + +function MyButton({variant, children}: MyButtonProps) { + return ( + + ); +} + + + Custom Button (Secondary Variant) + +``` + +When you spread `renderProps` together with your own props, the macro resolves both built-in states (hover, press) and your custom variants. + +### Nesting conditions + +Conditions can be nested to express "when A **and** B are both true." Conditions at the same level are mutually exclusive, so the last matching condition wins. + +```tsx render type="s2" +"use client"; +import {ToggleButton} from 'react-aria-components/ToggleButton'; +import {style, focusRing} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const toggleStyles = style({ + ...focusRing(), + backgroundColor: { + default: 'neutral-subtle', + /*- begin highlight -*/ + isSelected: { + default: 'accent', + isHovered: 'accent-700', + isSelected: 'accent-900' + } + /*- end highlight -*/ + }, + color: 'neutral', + paddingX: 16, + paddingY: 8, + borderRadius: 'pill', + borderStyle: 'none', + font: 'ui', + transition: 'default' +}); + +function MyToggle({children}) { + return {children}; +} + + + Custom Toggle + +``` + +The nesting here means: when `isSelected` is true **and** `isHovered` is also true, use `accent-900`. This lets you express complex state combinations without writing manual CSS selectors. + +## Base layers + +The visual foundation of a Spectrum component is typically a background color, a border, and interactive state variations. The `baseColor` utility generates all four interaction states (default, hovered, focused, pressed) from a single color token, matching the Spectrum 2 interaction model. + +```tsx +import {style, baseColor} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const customStyles = style({ + /*- begin highlight -*/ + backgroundColor: baseColor('accent'), + // Expands to: + // backgroundColor: { + // default: 'accent', + // isHovered: 'accent:hovered', + // isFocusVisible: 'accent:focused', + // isPressed: 'accent:pressed' + // } + /*- end highlight -*/ +}); +``` + +`baseColor` works with any Spectrum color token. It's especially useful for backgrounds and borders where you want consistent Spectrum interaction feedback. + +```tsx +const customStyles = style({ + /*- begin highlight -*/ + backgroundColor: baseColor('gray-50'), + borderColor: baseColor('gray-200'), + /*- end highlight -*/ +}); +``` + +### Disabled states + +For disabled states, use the semantic `disabled` color token. You can combine `baseColor` with disabled overrides by nesting conditions. + +```tsx +import {style, baseColor} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const customStyles = style({ + backgroundColor: { + ...baseColor('accent'), + isDisabled: 'disabled' + }, + color: { + default: 'white', + isDisabled: 'disabled' + } +}); +``` + +### Forced colors + +When building components that need to work in Windows High Contrast Mode, use `forcedColors` conditions with [system colors](https://developer.mozilla.org/en-US/docs/Web/CSS/system-color). + +```tsx +const customStyles = style({ + backgroundColor: { + ...baseColor('accent'), + isDisabled: 'disabled', + /*- begin highlight -*/ + forcedColors: { + default: 'ButtonFace', + isDisabled: 'ButtonFace' + } + /*- end highlight -*/ + }, + color: { + default: 'white', + forcedColors: 'ButtonText' + }, + borderColor: { + default: 'transparent', + forcedColors: 'ButtonBorder' + }, + forcedColorAdjust: 'none' +}); +``` + +These `forcedColors` condition is built-in to the `style` macro, so you don't need to pass it in yourself. + +## Spacing + +The `style` macro provides a spacing scale based on Spectrum 2 tokens. Spacing values are specified as numbers on the 4px grid (representing pixel values that are converted to `rem` at build time) and applied through properties like `padding`, `margin`, `gap`, and `inset`. + +```tsx +const customStyles = style({ + /*- begin highlight -*/ + padding: 24, + /*- end highlight -*/ + display: 'flex', + flexDirection: 'column', + /*- begin highlight -*/ + rowGap: 8, + /*- end highlight -*/ +}); +``` + +Logical properties like `paddingStart`, `paddingEnd`, `marginStart`, and `marginEnd` are available and automatically flip in right-to-left languages. + +```tsx +const listItemStyles = style({ + paddingX: 16, + paddingY: 8, + marginBottom: 4 +}); +``` + +### Responsive spacing + +Spacing values can vary by breakpoint using conditional objects. + +```tsx +const sectionStyles = style({ + padding: { + default: 16, + sm: 24, + lg: 32 + }, + rowGap: { + default: 8, + lg: 16 + } +}); +``` + +These scaling conditions are built-in to the `style` macro, so you don't need to pass them in yourself. + +### Sizing + +Width, height, and related sizing properties accept the same numeric scale, plus semantic values like `'fit'` (fit-content) and `'full'` (100%). + +```tsx +const avatarStyles = style({ + size: 40, + borderRadius: 'full' +}); + +const containerStyles = style({ + maxWidth: 640, + width: 'full', + marginX: 'auto' +}); +``` + +## Typography + +Spectrum 2 typography is applied via the `font` shorthand property, which sets `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, and `color` in one declaration. Individual properties can be overridden separately. + +The available font presets are grouped into four categories: + +- **Heading** – `heading-2xs`, `heading-xs`, `heading-sm`, `heading`, `heading-lg`, `heading-xl`, `heading-2xl`, `heading-3xl` +- **Title** – `title-xs`, `title-sm`, `title`, `title-lg`, `title-xl`, `title-2xl`, `title-3xl` +- **Body** – `body-xs`, `body-sm`, `body`, `body-lg`, `body-xl`, `body-2xl`, `body-3xl` +- **UI** – `ui-xs`, `ui-sm`, `ui`, `ui-lg`, `ui-xl` +- **Code** – `code-xs`, `code-sm`, `code`, `code-lg`, `code-xl` + +Apply `font` on a per-element basis rather than globally to properly conform with Spectrum designs. + +```tsx render type="s2" +"use client"; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const pageStyles = style({ + display: 'flex', + flexDirection: 'column', + rowGap: 8 +}); + +/*- begin highlight -*/ +const headingStyles = style({font: 'heading-lg', margin: 0}); +const subtitleStyles = style({font: 'detail', margin: 0}); +const bodyStyles = style({font: 'body', margin: 0}); +/*- end highlight -*/ + +export default function ArticlePage() { + return ( +
+

Article Title

+

A short description of the article.

+

The main body content goes here.

+
+ ); +} +``` + +### Semantic text colors + +Spectrum 2 provides semantic color tokens for text: `'neutral'` for primary text, `'neutral-subdued'` for secondary text, `'disabled'` for disabled states, and semantic status colors like `'negative'`, `'positive'`, `'informative'`, and `'notice'`. + +```tsx +const errorStyles = style({ + font: 'body-sm', + color: 'negative' +}); + +const helpTextStyles = style({ + font: 'body-sm', + color: 'neutral-subdued' +}); +``` + +## Focus rings + +The `focusRing` utility spreads into a `style` call and applies the standard Spectrum 2 focus ring on keyboard focus (`isFocusVisible`). + +```tsx +import {Button} from 'react-aria-components/Button'; +import {style, focusRing} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const buttonStyles = style({ + /*- begin highlight -*/ + ...focusRing(), + /*- end highlight -*/ + backgroundColor: 'accent', + color: 'neutral', + paddingX: 16, + paddingY: 8, + borderRadius: 'pill', + borderStyle: 'none', + font: 'ui', + transition: 'default' +}); +``` + +## Icons + +Spectrum 2 icons can be imported from `@react-spectrum/s2/icons` and styled with the `iconStyle` macro. This is a specialized version of `style` that sets the icon size and color. + +When placing icons inside custom components, you can set the `--iconPrimary` CSS variable to control the icon fill color from a parent style. + +```tsx render type="s2" +"use client"; +import {Button} from 'react-aria-components/Button'; +import {style, focusRing, iconStyle} from '@react-spectrum/s2/style' with {type: 'macro'}; +import EditIcon from '@react-spectrum/s2/icons/Edit'; + +const buttonStyles = style({ + ...focusRing(), + backgroundColor: 'accent', + /*- begin highlight -*/ + '--iconPrimary': { + type: 'fill', + value: 'white' + }, + /*- end highlight -*/ + color: 'neutral', + padding: 8, + borderRadius: 'pill', + borderStyle: 'none', + font: 'ui', + transition: 'default', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +function EditButton() { + return ( + + ); +} + + +``` + +## CSS variables + +CSS variables can be defined in a `style` call so that child elements can reference them. Provide a `type` to specify what CSS property category the value represents. + +```tsx +const parentStyles = style({ + /*- begin highlight -*/ + '--cardBg': { + type: 'backgroundColor', + value: 'gray-50' + }, + '--cardBorder': { + type: 'borderColor', + value: 'gray-200' + } + /*- end highlight -*/ +}); + +const childStyles = style({ + /*- begin highlight -*/ + backgroundColor: '--cardBg', + borderColor: '--cardBorder', + /*- end highlight -*/ + borderWidth: 1, + borderStyle: 'solid', + borderRadius: 'lg' +}); +``` + +This is useful when a parent component needs to influence the colors of deeply nested children without prop drilling. + +## CSS macro + +For cases where the `style` macro's object API doesn't cover what you need (pseudo-elements, complex selectors, animations), the `css` macro lets you inject raw CSS. + +Use this escape hatch sparingly since it bypasses the type safety and token constraints of the `style` macro. + +## Merging styles + +When you need to combine multiple `style` results, for example when merging a base style with an override, use `mergeStyles`. This correctly resolves atomic CSS class conflicts so the last value wins. + +```tsx +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {mergeStyles} from '@react-spectrum/s2'; + +const baseCard = style({ + padding: 24, + borderRadius: 'lg', + backgroundColor: 'gray-50' +}); + +const highlightedCard = style({ + backgroundColor: 'accent-100', + borderColor: 'accent', + borderWidth: 2, + borderStyle: 'solid' +}); + +function HighlightedCard({children}) { + return ( +
+ {children} +
+ ); +} +``` + + + Note + + `mergeStyles` is a **runtime** function imported from `@react-spectrum/s2` (not the style macro). + + + +## Best Practices + +When building custom S2 components, follow these best practices: + +- Only use `cursor: 'pointer'` for **links**. Avoid using it for buttons and other non-link actions. +- Use the `focusRing` utility on focusable components. +- Ensure that [WCAG color contrast requirements](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) are met when setting text and background colors. +- Use the `baseColor` utility for providing the base background color states for interactive components. +- Add `forcedColors` styles to ensure that components work in Windows High Contrast Mode. + +## Examples + +See the [Examples](examples) page for complete, interactive demonstrations of custom components built with React Aria and the Spectrum 2 style macro. diff --git a/packages/dev/s2-docs/pages/s2/examples/index.mdx b/packages/dev/s2-docs/pages/s2/examples/index.mdx new file mode 100644 index 00000000000..99bd0242cd1 --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/examples/index.mdx @@ -0,0 +1,12 @@ +import {Layout} from '../../../src/Layout'; +export default Layout; + +export const section = 'Overview'; +export const hideFromSearch = true; +export const title = 'Examples'; +export const isPostList = true; +export const description = 'Custom component examples built with React Aria and the Spectrum 2 style macro.'; + +# Examples + + diff --git a/packages/dev/s2-docs/pages/s2/examples/placeholder-card.mdx b/packages/dev/s2-docs/pages/s2/examples/placeholder-card.mdx new file mode 100644 index 00000000000..8bfe2e413b0 --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/examples/placeholder-card.mdx @@ -0,0 +1,79 @@ +import {Layout} from '../../../src/Layout'; +export default Layout; + +export const hideNav = true; +export const isSubpage = true; +export const section = 'Examples'; +export const keywords = ['react-spectrum', 's2', 'example', 'custom', 'style macro']; +export const description = 'TODO: Replace this with better examples.'; + +# Placeholder Card + +TODO: Replace this with better examples. + +```tsx render expanded +"use client"; +import {Link} from 'react-aria-components/Link'; +import {style, focusRing, baseColor} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const cardStyles = style({ + ...focusRing(), + display: 'flex', + flexDirection: 'column', + rowGap: 4, + padding: 24, + borderRadius: 'xl', + backgroundColor: baseColor('gray-50'), + borderWidth: 1, + borderStyle: 'solid', + borderColor: { + default: 'gray-200', + isHovered: 'gray-300', + isFocusVisible: 'gray-300' + }, + boxShadow: { + default: 'elevated', + isHovered: 'emphasized' + }, + textDecoration: 'none', + cursor: 'pointer', + transition: 'default', + width: 'full', + maxWidth: 320 +}); + +const titleStyles = style({font: 'title-sm', color: 'neutral'}); +const descriptionStyles = style({font: 'body-sm', color: 'neutral-subdued'}); +const metaStyles = style({font: 'ui-xs', color: 'neutral-subdued', marginTop: 8}); + +function Card({title, description, meta, href}: {title: string, description: string, meta?: string, href: string}) { + return ( + + {title} + {description} + {meta && {meta}} + + ); +} + +
+ + +
+``` + +This example shows several patterns for building interactive custom components: + +- **React Aria Link** – Wrapping the card in a `Link` provides keyboard navigation, focus management, and screen reader announcements. All hover, press, and focus states are handled automatically. +- **`focusRing()`** – Spreads the standard Spectrum 2 focus ring into the style, ensuring keyboard users see a visible indicator. +- **`baseColor()`** – Generates hover, focus, and press color variations from a single token. Here it's used for the background, so the card subtly shifts color on interaction. +- **`boxShadow`** – Spectrum tokens like `elevated` and `emphasized` provide consistent depth across the design system. +- **Typography tokens** – `title-sm`, `body-sm`, and `ui-xs` apply the correct font family, size, weight, and line height in one property. diff --git a/packages/dev/s2-docs/src/ExampleList.tsx b/packages/dev/s2-docs/src/ExampleList.tsx index 49369fb931e..52e61918838 100644 --- a/packages/dev/s2-docs/src/ExampleList.tsx +++ b/packages/dev/s2-docs/src/ExampleList.tsx @@ -30,9 +30,9 @@ export const images: Record = { 'swipeable-tabs': [swipeableTabs, swipeableTabsDark] }; -export function ExampleList({tag, pages}) { +export function ExampleList({tag, pages, prefix = 'react-aria/examples/'}) { let examples = pages - .filter(page => page.name.startsWith('react-aria/examples/') && !page.name.endsWith('index') && (!tag || page.exports?.keywords.includes(tag))) + .filter(page => page.name.startsWith(prefix) && !page.name.endsWith('index') && (!tag || page.exports?.keywords.includes(tag))) .sort((a, b) => getTitle(a).localeCompare(getTitle(b))); return ( @@ -66,9 +66,11 @@ export function ExampleList({tag, pages}) { itemType="https://schema.org/TechArticle"> - - - + {images[path.basename(example.name)] && ( + + + + )} {getTitle(example)} {example.exports?.description ? {example.exports?.description} : null}