Skip to content

Commit 4ccb30c

Browse files
refactor: replace as prop with render prop in Text and Headline (#740)
* refactor: replace `as` prop with `render` prop in Text and Headline Migrate Text and Headline components from the brittle `as` polymorphic prop pattern to Base UI's `render` prop + `useRender` hook, matching the pattern already used by the Flex component. This eliminates manual type unions, removes a `@ts-expect-error` suppression, and enables rendering as any element via JSX or render functions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: fix render prop playground controls to use JSX element syntax The render prop accepts ReactElement values, not strings. Update playground select options to use JSX element strings (e.g. '<h2 />') so getPropsString generates correct `render={<h2 />}` syntax. Also document the render prop in Headline's API Reference section. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: update V1 migration guide for Text and Headline render prop Add migration documentation for the breaking change where `as` prop is replaced by `render` on Text and Headline components. Includes before/after examples, TypeScript type change notes, and render function usage. Updates Table of Contents, cross-cutting changes note, and Migration Checklist. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9b25379 commit 4ccb30c

13 files changed

Lines changed: 187 additions & 66 deletions

File tree

apps/www/src/components/playground/headline-examples.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ export function HeadlineExamples() {
88
<PlaygroundLayout title='Headline'>
99
<Flex direction='column' gap='large'>
1010
<Flex direction='column' gap='large'>
11-
<Headline size='large' as='h1'>
11+
<Headline size='large' render={<h1 />}>
1212
Large Headline
1313
</Headline>
1414

1515
<Headline size='medium'>Medium Headline</Headline>
1616

17-
<Headline size='small' as='h3'>
17+
<Headline size='small' render={<h3 />}>
1818
Small Headline
1919
</Headline>
2020
</Flex>

apps/www/src/content/docs/components/headline/demo.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ export const playground = {
2020
options: ['regular', 'medium'],
2121
defaultValue: 'medium'
2222
},
23-
as: {
23+
render: {
2424
type: 'select',
25-
options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
26-
defaultValue: 'h2'
25+
options: ['<h1 />', '<h2 />', '<h3 />', '<h4 />', '<h5 />', '<h6 />'],
26+
defaultValue: '<h2 />'
2727
},
2828
align: {
2929
type: 'select',

apps/www/src/content/docs/components/headline/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { Headline } from '@raystack/apsara'
2020

2121
## API Reference
2222

23-
Renders a heading element with configurable size and weight.
23+
Renders a heading element with configurable size and weight. Use the `render` prop to change the heading level or provide a custom render function.
2424

2525
<auto-type-table path="./props.ts" name="HeadlineProps" />
2626

apps/www/src/content/docs/components/headline/props.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ export interface HeadlineProps {
1212
weight?: 'regular' | 'medium';
1313

1414
/**
15-
* HTML heading element to render.
15+
* Custom render element or function. Accepts a JSX element (e.g. `<h1 />`)
16+
* or a render function for full control.
1617
* @default "h2"
1718
*/
18-
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
19+
render?:
20+
| React.ReactElement
21+
| ((props: React.ComponentPropsWithRef<'h2'>) => React.ReactElement);
1922

2023
/**
2124
* Text alignment.

apps/www/src/content/docs/components/text/demo.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ export const playground = {
2323
],
2424
defaultValue: 'primary'
2525
},
26-
as: {
26+
render: {
2727
type: 'select',
28-
options: ['span', 'p', 'div', 'label', 'a'],
29-
defaultValue: 'span'
28+
options: ['<span />', '<p />', '<div />', '<label />', '<a />'],
29+
defaultValue: '<span />'
3030
},
3131
size: {
3232
type: 'select',

apps/www/src/content/docs/components/text/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { Text } from '@raystack/apsara'
2929

3030
## API Reference
3131

32-
According to the element rendered using `as`, Text will extend over the default HTML Attributes
32+
Use the `render` prop to change the rendered element or provide a custom render function.
3333

3434
<auto-type-table path="./props.ts" name="TextProps" />
3535

apps/www/src/content/docs/components/text/props.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
export interface TextProps {
22
/**
3-
* Text element to render as.
3+
* Custom render element or function. Accepts a JSX element (e.g. `<p />`)
4+
* or a render function for full control.
45
* @default "span"
56
*/
6-
as?: 'span' | 'p' | 'div' | 'label' | 'a';
7+
render?:
8+
| React.ReactElement
9+
| ((props: React.ComponentPropsWithRef<'span'>) => React.ReactElement);
710

811
/**
912
* The visual style variant.

docs/V1-migration.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ This guide covers all breaking changes when upgrading from the last stable Radix
3232
- [New Features](#new-features-2)
3333
- [Flex](#flex)
3434
- [Grid](#grid)
35+
- [Headline](#headline)
3536
- [InputField](#inputfield)
3637
- [Popover](#popover)
3738
- [New Features](#new-features-3)
@@ -49,6 +50,7 @@ This guide covers all breaking changes when upgrading from the last stable Radix
4950
- [Switch](#switch)
5051
- [Tabs](#tabs)
5152
- [New Features](#new-features-8)
53+
- [Text](#text)
5254
- [TextArea](#textarea)
5355
- [Toast](#toast)
5456
- [New Features](#new-features-9)
@@ -110,6 +112,8 @@ Radix's `asChild` composition pattern is gone. Use the `render` prop instead. No
110112

111113
Affected components: Button, Grid, Grid.Item, Popover.Trigger, Menu.Trigger, Drawer.Trigger, Dialog.Trigger, Dialog.Close, AlertDialog.Trigger, Breadcrumb.Item, Tooltip.Trigger.
112114

115+
> **Note:** Text and Headline also replace their `as` prop with `render`, but using a different pattern. Instead of `asChild` (which forwarded all props to a child element), `as` was a simple string tag name. The new `render` prop accepts a JSX element or a render function, matching the Base UI convention. See [Text](#text) and [Headline](#headline) for details.
116+
113117
### Callback Signatures
114118

115119
Most `onValueChange`, `onOpenChange`, and `onCheckedChange` callbacks now receive an optional second `eventDetails` argument:
@@ -767,6 +771,35 @@ Type changed to `useRender.ComponentProps<'div'>` -- may cause TypeScript errors
767771

768772
---
769773

774+
### Headline
775+
776+
**`as` prop replaced by `render`:**
777+
778+
The `as` prop accepted a string tag name (`'h1'` through `'h6'`). The new `render` prop accepts a JSX element or a render function, consistent with the Base UI pattern used across the library. The default element remains `<h2>`.
779+
780+
```tsx
781+
// Before
782+
<Headline as="h1" size="large">Page Title</Headline>
783+
<Headline as="h3" size="small">Section Title</Headline>
784+
<Headline>Default h2</Headline>
785+
786+
// After
787+
<Headline render={<h1 />} size="large">Page Title</Headline>
788+
<Headline render={<h3 />} size="small">Section Title</Headline>
789+
<Headline>Default h2</Headline>
790+
```
791+
792+
The `render` prop also supports render functions for full control:
793+
794+
```tsx
795+
// Render function for custom element
796+
<Headline render={props => <h1 {...props} />}>Page Title</Headline>
797+
```
798+
799+
**TypeScript:** The type changed from `HeadlineBaseProps & ComponentProps<'h1'> & { as?: 'h1' | ... | 'h6' }` to `HeadlineBaseProps & useRender.ComponentProps<'h2'>`. If you explicitly typed Headline props, update to `HeadlineProps` (now exported).
800+
801+
---
802+
770803
### InputField
771804

772805
**Label, helper text, error, and optional indicator moved to `Field` wrapper.** InputField is now a pure input control — wrap it with the new `Field` component for labels, descriptions, and error messages.
@@ -1283,6 +1316,49 @@ Key changes:
12831316

12841317
---
12851318

1319+
### Text
1320+
1321+
**`as` prop replaced by `render`:**
1322+
1323+
The `as` prop accepted a string tag name (`'span'`, `'p'`, `'div'`, `'label'`, `'a'`). The new `render` prop accepts a JSX element or a render function, consistent with the Base UI pattern used across the library. The default element remains `<span>`.
1324+
1325+
```tsx
1326+
// Before
1327+
<Text as="label">Username</Text>
1328+
<Text as="p">Paragraph text</Text>
1329+
<Text as="a" href="/link">Click here</Text>
1330+
<Text>Default span</Text>
1331+
1332+
// After
1333+
<Text render={<label />}>Username</Text>
1334+
<Text render={<p />}>Paragraph text</Text>
1335+
<Text render={<a href="/link" />}>Click here</Text>
1336+
<Text>Default span</Text>
1337+
```
1338+
1339+
Note that HTML attributes specific to the rendered element (like `href` for anchors, `htmlFor` for labels) now go on the JSX element inside `render`, not on `Text` itself:
1340+
1341+
```tsx
1342+
// Before
1343+
<Text as="label" htmlFor="email-input">Email</Text>
1344+
<Text as="a" href="#section" target="_blank">Link</Text>
1345+
1346+
// After
1347+
<Text render={<label htmlFor="email-input" />}>Email</Text>
1348+
<Text render={<a href="#section" target="_blank" />}>Link</Text>
1349+
```
1350+
1351+
The `render` prop also supports render functions for full control:
1352+
1353+
```tsx
1354+
// Render function for any element
1355+
<Text render={props => <section {...props} />}>Custom element</Text>
1356+
```
1357+
1358+
**TypeScript:** The type changed from a discriminated union (`TextSpanProps | TextDivProps | TextLabelProps | TextPProps | TextAProps`) to `TextBaseProps & useRender.ComponentProps<'span'>`. The `render` prop now accepts any element, so you are no longer limited to the five original tag names. The `// @ts-expect-error` that was needed internally for polymorphic refs is also gone.
1359+
1360+
---
1361+
12861362
### TextArea
12871363

12881364
**Label, helper text, error, and optional indicator moved to `Field` wrapper.** TextArea is now a pure textarea control — wrap it with the new `Field` component for labels, descriptions, and error messages.
@@ -1596,6 +1672,8 @@ These are purely additive -- no migration needed.
15961672
- [ ] Upgrade to React 19
15971673
- [ ] Update CSS import order (`normalize.css` before `style.css`)
15981674
- [ ] Global find-and-replace: `asChild` -> `render` prop pattern
1675+
- [ ] Replace `Text as="..."` with `Text render={<element />}` (see [Text](#text))
1676+
- [ ] Replace `Headline as="..."` with `Headline render={<element />}` (see [Headline](#headline))
15991677
- [ ] Update callback signatures where TypeScript complains
16001678
- [ ] Replace `DropdownMenu` imports with `Menu`
16011679
- [ ] Replace `Sheet` imports with `Drawer`

packages/raystack/components/headline/__tests__/headline.test.tsx

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,48 @@ describe('Headline', () => {
3030
});
3131
});
3232

33-
describe('As Prop', () => {
34-
const headingLevels = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const;
33+
describe('Render Prop', () => {
34+
it('renders as h1 via render prop', () => {
35+
render(<Headline render={<h1 />}>Heading</Headline>);
36+
const heading = screen.getByText('Heading');
37+
expect(heading.tagName).toBe('H1');
38+
});
39+
40+
it('renders as h3 via render prop', () => {
41+
render(<Headline render={<h3 />}>Heading</Headline>);
42+
const heading = screen.getByText('Heading');
43+
expect(heading.tagName).toBe('H3');
44+
});
45+
46+
it('renders as h4 via render prop', () => {
47+
render(<Headline render={<h4 />}>Heading</Headline>);
48+
const heading = screen.getByText('Heading');
49+
expect(heading.tagName).toBe('H4');
50+
});
51+
52+
it('renders as h5 via render prop', () => {
53+
render(<Headline render={<h5 />}>Heading</Headline>);
54+
const heading = screen.getByText('Heading');
55+
expect(heading.tagName).toBe('H5');
56+
});
3557

36-
it.each(headingLevels)('renders as %s element', level => {
37-
render(<Headline as={level}>Heading</Headline>);
58+
it('renders as h6 via render prop', () => {
59+
render(<Headline render={<h6 />}>Heading</Headline>);
3860
const heading = screen.getByText('Heading');
39-
expect(heading.tagName).toBe(level.toUpperCase());
61+
expect(heading.tagName).toBe('H6');
4062
});
4163

4264
it('renders as h2 by default', () => {
4365
render(<Headline>Heading</Headline>);
4466
const heading = screen.getByText('Heading');
4567
expect(heading.tagName).toBe('H2');
4668
});
69+
70+
it('supports render function', () => {
71+
render(<Headline render={props => <h1 {...props} />}>Heading</Headline>);
72+
const heading = screen.getByText('Heading');
73+
expect(heading.tagName).toBe('H1');
74+
});
4775
});
4876

4977
describe('Sizes', () => {

packages/raystack/components/headline/headline.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { mergeProps, useRender } from '@base-ui/react';
12
import { cva, type VariantProps } from 'class-variance-authority';
2-
import { ComponentProps } from 'react';
33
import styles from './headline.module.css';
44

55
const headline = cva(styles.headline, {
@@ -41,26 +41,29 @@ export type HeadlineBaseProps = VariantProps<typeof headline> & {
4141
size?: 't1' | 't2' | 't3' | 't4' | 'small' | 'medium' | 'large';
4242
};
4343

44-
type HeadlineProps = HeadlineBaseProps &
45-
ComponentProps<'h1'> & {
46-
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
47-
};
44+
export type HeadlineProps = HeadlineBaseProps & useRender.ComponentProps<'h2'>;
4845

4946
export function Headline({
5047
className,
5148
size,
5249
weight,
5350
align,
5451
truncate,
55-
as: Component = 'h2',
52+
render,
53+
ref,
5654
...props
5755
}: HeadlineProps) {
58-
return (
59-
<Component
60-
className={headline({ size, weight, align, truncate, className })}
61-
{...props}
62-
/>
63-
);
56+
const element = useRender({
57+
defaultTagName: 'h2',
58+
ref,
59+
render,
60+
props: mergeProps<'h2'>(
61+
{ className: headline({ size, weight, align, truncate, className }) },
62+
props
63+
)
64+
});
65+
66+
return element;
6467
}
6568

6669
Headline.displayName = 'Headline';

0 commit comments

Comments
 (0)