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}