diff --git a/.changeset/mosaic-card-stylex.md b/.changeset/mosaic-card-stylex.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-card-stylex.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/swingset/src/stories/card.component.mdx b/packages/swingset/src/stories/card.component.mdx
index 5f282a30ea2..38444cd4842 100644
--- a/packages/swingset/src/stories/card.component.mdx
+++ b/packages/swingset/src/stories/card.component.mdx
@@ -20,11 +20,11 @@ A styled surface container that groups related content into Header, Content, and
```tsx
import { Card } from '@clerk/ui/mosaic/components/card';
-
+
Heading
Body content.
Footer actions
-
+
```
---
diff --git a/packages/swingset/src/stories/card.component.stories.tsx b/packages/swingset/src/stories/card.component.stories.tsx
index ae72fe95628..201fd081626 100644
--- a/packages/swingset/src/stories/card.component.stories.tsx
+++ b/packages/swingset/src/stories/card.component.stories.tsx
@@ -1,7 +1,7 @@
/** @jsxImportSource @emotion/react */
import { Button } from '@clerk/ui/mosaic/components/button';
import type { CardProps } from '@clerk/ui/mosaic/components/card';
-import { Card, cardRecipe } from '@clerk/ui/mosaic/components/card';
+import { Card } from '@clerk/ui/mosaic/components/card';
import { Heading } from '@clerk/ui/mosaic/components/heading';
import { Text } from '@clerk/ui/mosaic/components/text';
@@ -14,8 +14,18 @@ export { default as __source } from './card.component.stories?raw';
export const meta: StoryMeta = {
group: 'Components',
title: 'Card',
- source: 'packages/ui/src/mosaic/components/card.tsx',
- styles: cardRecipe,
+ source: 'packages/ui/src/mosaic/components/card/card.tsx',
+ styleEngine: 'stylex',
+ styles: {
+ _variants: {
+ alignment: { start: {}, center: {} },
+ elevation: { card: {}, flush: {}, overlay: {} },
+ },
+ _defaultVariants: {
+ alignment: 'start',
+ elevation: 'card',
+ },
+ },
};
function knobsAsProps(props: Record) {
@@ -24,7 +34,7 @@ function knobsAsProps(props: Record) {
export function Default(props: Record) {
return (
-
@@ -36,13 +46,13 @@ export function Default(props: Record) {
-
+
);
}
export function Centered() {
return (
-
@@ -54,6 +64,6 @@ export function Centered() {
-
+
);
}
diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx
index 08dc13b078a..9ce80bcbbb2 100644
--- a/packages/swingset/src/stories/popover.component.mdx
+++ b/packages/swingset/src/stories/popover.component.mdx
@@ -29,7 +29,7 @@ import { Popover } from '@clerk/ui/mosaic/components/popover';
} />
-
+
Flexible inner content.
-
+
;
```
@@ -61,9 +61,9 @@ neither logs a development warning.
Open
-
+
Flexible inner content.
-
+
;
```
@@ -79,9 +79,9 @@ const [open, setOpen] = useState(false);
>
Open
-
+
Flexible inner content.
-
+
;
```
@@ -97,9 +97,9 @@ region.
Open
-
+
A wider panel.
-
+
;
```
@@ -135,9 +135,9 @@ centering on it.
>
Open
-
+
Aligned to the trigger's end edge.
-
+
;
```
diff --git a/packages/swingset/src/stories/popover.component.stories.tsx b/packages/swingset/src/stories/popover.component.stories.tsx
index 1e923360fb3..b775dbc7f64 100644
--- a/packages/swingset/src/stories/popover.component.stories.tsx
+++ b/packages/swingset/src/stories/popover.component.stories.tsx
@@ -23,7 +23,7 @@ export function Default() {
} />
-
+
Ada Lovelace
ada@example.com
@@ -36,7 +36,7 @@ export function Default() {
Sign out of all accounts
-
+
);
@@ -48,11 +48,11 @@ export function Default() {
const labelledTrigger = (label: string) => ;
const panel = (label: string) => (
-
+
{label}
-
+
);
export function Placement() {
diff --git a/packages/ui/src/mosaic/components/card.tsx b/packages/ui/src/mosaic/components/card.tsx
deleted file mode 100644
index e616f6f79a9..00000000000
--- a/packages/ui/src/mosaic/components/card.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import React from 'react';
-
-import type { RecipeVariantProps } from '../slot-recipe';
-import { defineSlotRecipe, useRecipe } from '../slot-recipe';
-import { TextContext } from './text';
-
-export const cardRecipe = defineSlotRecipe(theme => ({
- slots: {
- root: { slot: 'card-root' },
- header: { slot: 'card-header' },
- content: { slot: 'card-content' },
- footer: { slot: 'card-footer' },
- },
- base: {
- root: {
- backgroundColor: theme.color.card,
- color: theme.color.cardForeground,
- borderRadius: theme.rounded.lg,
- display: 'flex',
- flexDirection: 'column',
- rowGap: theme.spacing(5),
- width: '100%',
- overflow: 'hidden',
- border: `1px solid ${theme.color.border}`,
- },
- header: {
- display: 'flex',
- flexDirection: 'column',
- paddingBlockStart: theme.spacing(5),
- paddingInline: theme.spacing(4),
- },
- content: {
- paddingInline: theme.spacing(4),
- flex: '1 1 auto',
- },
- footer: {
- paddingBlockEnd: theme.spacing(5),
- paddingInline: theme.spacing(4),
- },
- },
- variants: {
- alignment: {
- start: {
- header: { alignItems: 'flex-start', textAlign: 'start' },
- },
- center: {
- header: { alignItems: 'center', textAlign: 'center' },
- },
- },
- },
- defaultVariants: {
- alignment: 'start',
- },
-}));
-
-type CardVariantProps = RecipeVariantProps;
-
-const CardVariantContext = React.createContext({});
-
-declare module '../registry' {
- interface MosaicSlotRegistry {
- 'card-root': true;
- 'card-header': true;
- 'card-content': true;
- 'card-footer': true;
- }
-}
-
-const Root = React.forwardRef>(function CardRoot(props, ref) {
- const variantProps = React.useContext(CardVariantContext);
- const { root } = useRecipe(cardRecipe, { variants: variantProps });
- return (
-
- );
-});
-
-const Header = React.forwardRef>(function CardHeader(props, ref) {
- const variantProps = React.useContext(CardVariantContext);
- const { header } = useRecipe(cardRecipe, { variants: variantProps });
- return (
-
-
-
- );
-});
-
-const Content = React.forwardRef>(
- function CardContent(props, ref) {
- const { content } = useRecipe(cardRecipe);
- return (
-
- );
- },
-);
-
-const Footer = React.forwardRef>(function CardFooter(props, ref) {
- const { footer } = useRecipe(cardRecipe);
- return (
-
- );
-});
-
-export interface CardProps extends React.ComponentPropsWithoutRef<'div'> {
- alignment?: CardVariantProps['alignment'];
-}
-
-export function Card({ alignment, children, ...props }: CardProps) {
- return (
-
- {children}
-
- );
-}
-
-Card.Root = Root;
-Card.Header = Header;
-Card.Content = Content;
-Card.Footer = Footer;
diff --git a/packages/ui/src/mosaic/components/card/card.styles.ts b/packages/ui/src/mosaic/components/card/card.styles.ts
new file mode 100644
index 00000000000..035231712d8
--- /dev/null
+++ b/packages/ui/src/mosaic/components/card/card.styles.ts
@@ -0,0 +1,75 @@
+import * as stylex from '@stylexjs/stylex';
+
+import { colorVars, radiusVars, space } from '../../tokens.stylex';
+
+export const styles = stylex.create({
+ root: {
+ color: colorVars['--cl-color-card-foreground'],
+ display: 'flex',
+ flexDirection: 'column',
+ rowGap: space['5'],
+ width: '100%',
+ },
+ header: {
+ paddingInline: space['4'],
+ display: 'flex',
+ flexDirection: 'column',
+ paddingBlockStart: space['5'],
+ },
+ content: {
+ paddingInline: space['4'],
+ flexBasis: 'auto',
+ flexGrow: '1',
+ flexShrink: '1',
+ },
+ footer: {
+ gap: space['2'],
+ paddingBlock: space['4'],
+ paddingInline: space['6'],
+ alignItems: 'center',
+ boxSizing: 'border-box',
+ display: 'flex',
+ flexShrink: 0,
+ justifyContent: 'space-between',
+ borderTopColor: colorVars['--cl-color-border'],
+ borderTopStyle: 'solid',
+ borderTopWidth: '1px',
+ width: '100%',
+ },
+});
+
+export const elevations = stylex.create({
+ card: {
+ borderRadius: radiusVars['--cl-radius-container'],
+ overflow: 'hidden',
+ backgroundColor: colorVars['--cl-color-card'],
+ boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent),
+ 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent),
+ 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`,
+ },
+ flush: {
+ borderRadius: radiusVars['--cl-radius-container'],
+ overflow: 'visible',
+ backgroundColor: 'transparent',
+ boxShadow: 'none',
+ },
+ overlay: {
+ borderRadius: radiusVars['--cl-radius-container'],
+ overflow: 'hidden',
+ backgroundColor: colorVars['--cl-color-card'],
+ boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent),
+ 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent),
+ 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`,
+ },
+});
+
+export const headerAlignments = stylex.create({
+ start: {
+ alignItems: 'flex-start',
+ textAlign: 'start',
+ },
+ center: {
+ alignItems: 'center',
+ textAlign: 'center',
+ },
+});
diff --git a/packages/ui/src/mosaic/components/card/card.test.tsx b/packages/ui/src/mosaic/components/card/card.test.tsx
new file mode 100644
index 00000000000..2561a816d6e
--- /dev/null
+++ b/packages/ui/src/mosaic/components/card/card.test.tsx
@@ -0,0 +1,154 @@
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+import { Text } from '../text';
+import { Card } from './card';
+
+describe('Mosaic Card', () => {
+ it('renders each compound slot with the default alignment', () => {
+ render(
+
+ Header
+ Content
+ Footer
+ ,
+ );
+
+ expect(screen.getByTestId('root')).toHaveClass('cl-card-root');
+ expect(screen.getByTestId('root')).toHaveAttribute('data-alignment', 'start');
+ expect(screen.getByTestId('root')).toHaveAttribute('data-elevation', 'card');
+ expect(screen.getByTestId('header')).toHaveClass('cl-card-header');
+ expect(screen.getByTestId('header')).toHaveAttribute('data-alignment', 'start');
+ expect(screen.getByTestId('content')).toHaveClass('cl-card-content');
+ expect(screen.getByTestId('footer')).toHaveClass('cl-card-footer');
+ expect(screen.getByTestId('footer')).toHaveAttribute('data-elevation', 'card');
+ });
+
+ it('reflects centered alignment on the root and header', () => {
+ render(
+
+ Header
+ ,
+ );
+
+ expect(screen.getByTestId('root')).toHaveAttribute('data-alignment', 'center');
+ expect(screen.getByTestId('header')).toHaveAttribute('data-alignment', 'center');
+ });
+
+ it('reflects flush elevation on the root and footer', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('root')).toHaveAttribute('data-elevation', 'flush');
+ expect(screen.getByTestId('footer')).toHaveAttribute('data-elevation', 'flush');
+ });
+
+ it('reflects overlay elevation on the root and footer', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('root')).toHaveAttribute('data-elevation', 'overlay');
+ expect(screen.getByTestId('footer')).toHaveAttribute('data-elevation', 'overlay');
+ });
+
+ it('provides the neutral text color to header copy', () => {
+ render(
+
+
+ Supporting copy
+
+ ,
+ );
+
+ expect(screen.getByText('Supporting copy')).toHaveAttribute('data-color', 'neutral');
+ });
+
+ it('lets consumer className and style win on every slot', () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('root')).toHaveClass('cl-card-root', 'my-card');
+ expect(screen.getByTestId('root')).toHaveStyle({ width: '20rem' });
+ expect(screen.getByTestId('header')).toHaveClass('cl-card-header', 'my-header');
+ expect(screen.getByTestId('header')).toHaveStyle({ textAlign: 'right' });
+ expect(screen.getByTestId('content')).toHaveClass('cl-card-content', 'my-content');
+ expect(screen.getByTestId('content')).toHaveStyle({ paddingInline: 0 });
+ expect(screen.getByTestId('footer')).toHaveClass('cl-card-footer', 'my-footer');
+ expect(screen.getByTestId('footer')).toHaveStyle({ paddingBlockEnd: 0 });
+ });
+
+ it('forwards refs and arbitrary props from compound slots', () => {
+ const rootRef = React.createRef();
+ const headerRef = React.createRef();
+ const contentRef = React.createRef();
+ const footerRef = React.createRef();
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(rootRef.current).toBe(screen.getByLabelText('Card'));
+ expect(headerRef.current).toHaveClass('cl-card-header');
+ expect(contentRef.current).toHaveClass('cl-card-content');
+ expect(footerRef.current).toHaveClass('cl-card-footer');
+ });
+
+ it('supports custom elements through render on every slot', () => {
+ render(
+ }>
+ } />
+ Content} />
+ } />
+ ,
+ );
+
+ expect(screen.getByText('Header').tagName).toBe('HEADER');
+ expect(screen.getByText('Content').tagName).toBe('MAIN');
+ expect(screen.getByText('Footer').tagName).toBe('FOOTER');
+ expect(screen.getByText('Header').closest('section')).toHaveClass('cl-card-root');
+ });
+});
diff --git a/packages/ui/src/mosaic/components/card/card.tsx b/packages/ui/src/mosaic/components/card/card.tsx
new file mode 100644
index 00000000000..134aca83714
--- /dev/null
+++ b/packages/ui/src/mosaic/components/card/card.tsx
@@ -0,0 +1,109 @@
+import { useRender } from '@clerk/headless/utils';
+import * as stylex from '@stylexjs/stylex';
+import React from 'react';
+
+import type { MosaicComponentProps } from '../../props';
+import { mergeStyleProps, themeProps } from '../../props';
+import { TextContext } from '../text';
+import { elevations, headerAlignments, styles } from './card.styles';
+
+type CardAlignment = 'start' | 'center';
+type CardElevation = 'card' | 'flush' | 'overlay';
+
+const DEFAULT_ALIGNMENT: CardAlignment = 'start';
+const DEFAULT_ELEVATION: CardElevation = 'card';
+
+const CardVariantContext = React.createContext<{ alignment: CardAlignment; elevation: CardElevation }>({
+ alignment: DEFAULT_ALIGNMENT,
+ elevation: DEFAULT_ELEVATION,
+});
+
+/** Props for `Card.Root`, including native `div` props and the Mosaic `render` escape hatch. */
+export interface CardProps extends MosaicComponentProps<'div'> {
+ /** Alignment applied to `Card.Header`. @default 'start' */
+ alignment?: CardAlignment;
+ /** Surface treatment applied to the card. @default 'card' */
+ elevation?: CardElevation;
+}
+
+const Root = React.forwardRef(function CardRoot(
+ { alignment = DEFAULT_ALIGNMENT, elevation = DEFAULT_ELEVATION, render, className, style, ...rest },
+ ref,
+) {
+ const element = useRender({
+ defaultTagName: 'div',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(
+ themeProps('card-root', { alignment, elevation }),
+ stylex.props(styles.root, elevations[elevation]),
+ className,
+ style,
+ ),
+ ...rest,
+ },
+ });
+
+ return {element};
+});
+
+const Header = React.forwardRef>(function CardHeader(
+ { render, className, style, ...rest },
+ ref,
+) {
+ const { alignment } = React.useContext(CardVariantContext);
+ const element = useRender({
+ defaultTagName: 'div',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(
+ themeProps('card-header', { alignment }),
+ stylex.props(styles.header, headerAlignments[alignment]),
+ className,
+ style,
+ ),
+ ...rest,
+ },
+ });
+
+ return {element};
+});
+
+const Content = React.forwardRef>(function CardContent(
+ { render, className, style, ...rest },
+ ref,
+) {
+ return useRender({
+ defaultTagName: 'div',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(themeProps('card-content'), stylex.props(styles.content), className, style),
+ ...rest,
+ },
+ });
+});
+
+const Footer = React.forwardRef>(function CardFooter(
+ { render, className, style, ...rest },
+ ref,
+) {
+ const { elevation } = React.useContext(CardVariantContext);
+ return useRender({
+ defaultTagName: 'div',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(themeProps('card-footer', { elevation }), stylex.props(styles.footer), className, style),
+ ...rest,
+ },
+ });
+});
+
+/**
+ * A styled surface composed through `Card.Root`, `Card.Header`, `Card.Content`, and `Card.Footer`.
+ * Every part accepts the Mosaic `render` prop and forwards its ref.
+ */
+export const Card = { Root, Header, Content, Footer };
diff --git a/packages/ui/src/mosaic/components/card/index.ts b/packages/ui/src/mosaic/components/card/index.ts
new file mode 100644
index 00000000000..659228ff6fd
--- /dev/null
+++ b/packages/ui/src/mosaic/components/card/index.ts
@@ -0,0 +1,2 @@
+export { Card } from './card';
+export type { CardProps } from './card';
diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts
index e18ee008525..b6094e9f27c 100644
--- a/packages/ui/src/mosaic/styles/index.ts
+++ b/packages/ui/src/mosaic/styles/index.ts
@@ -12,6 +12,8 @@ export { Badge } from '../components/badge';
export type { BadgeProps } from '../components/badge';
export { Button } from '../components/button';
export type { ButtonProps } from '../components/button';
+export { Card } from '../components/card';
+export type { CardProps } from '../components/card';
export { Heading, HeadingContext } from '../components/heading';
export type { HeadingProps } from '../components/heading';
export { Icon } from '../components/icon';