diff --git a/.changeset/sharp-pugs-sing.md b/.changeset/sharp-pugs-sing.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/sharp-pugs-sing.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/ui/src/ceramic/button.ts b/packages/ui/src/ceramic/button.ts
new file mode 100644
index 00000000000..21326c01cfe
--- /dev/null
+++ b/packages/ui/src/ceramic/button.ts
@@ -0,0 +1,79 @@
+import { style, variants } from './variants';
+
+export const buttonStyles = variants({
+ base: style(theme => ({
+ appearance: 'none',
+ boxSizing: 'border-box',
+ position: 'relative',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: theme.spacing[1],
+ height: theme.spacing[7],
+ borderRadius: theme.spacing[1.5],
+ fontSize: theme.typography.label[3].fontSize,
+ fontWeight: theme.fontWeights.medium,
+ fontFamily: theme.fontFamilies.sans,
+ border: 'none',
+ outline: 'none',
+ margin: 0,
+ paddingBlock: 0,
+ paddingInline: theme.spacing[3],
+ cursor: 'pointer',
+ textDecoration: 'none',
+ ...theme.fontRendering,
+ isolation: 'isolate',
+ transition: '300ms cubic-bezier(0.4, 0.36, 0, 1)',
+ '&::before': {
+ content: '""',
+ position: 'absolute',
+ inset: 0,
+ zIndex: -1,
+ borderRadius: 'inherit',
+ pointerEvents: 'none',
+ background: `linear-gradient(180deg, ${theme.alpha(theme.colors.white, 20)} 0%, ${theme.alpha(theme.colors.white, 0)} 100%)`,
+ opacity: 0.5,
+ transition: 'opacity 300ms cubic-bezier(0.4, 0.36, 0, 1)',
+ },
+ '&::after': {
+ content: '""',
+ position: 'absolute',
+ inset: 0,
+ zIndex: -1,
+ borderRadius: 'inherit',
+ pointerEvents: 'none',
+ background: `linear-gradient(180deg, ${theme.alpha(theme.colors.white, 10)} 46%, ${theme.alpha(theme.colors.white, 0)} 54%)`,
+ mixBlendMode: 'overlay',
+ },
+ '@media (hover: hover)': {
+ '&:hover::before': {
+ opacity: 1,
+ },
+ },
+ '&:focus-visible': {
+ ...theme.focusRing(),
+ },
+ })),
+ variants: {
+ variant: {
+ primary: style(theme => ({
+ background: theme.colors.purple[700],
+ color: theme.colors.white,
+ boxShadow: `${theme.colors.white} 0px 0px 0px 0px, ${theme.colors.purple[700]} 0px 0px 0px 1px, ${theme.alpha(theme.colors.white, 7)} 0px 1px 0px 0px inset, ${theme.alpha(theme.colors.gray[1300], 20)} 0px 1px 3px 0px`,
+ })),
+ secondary: style(theme => ({
+ background: theme.colors.gray[1100],
+ color: theme.colors.white,
+ boxShadow: `${theme.colors.white} 0px 0px 0px 0px, ${theme.colors.gray[1100]} 0px 0px 0px 1px, ${theme.alpha(theme.colors.white, 7)} 0px 1px 0px 0px inset, ${theme.alpha(theme.colors.gray[1300], 20)} 0px 1px 3px 0px`,
+ })),
+ },
+ fullWidth: {
+ true: { width: '100%' },
+ false: {},
+ },
+ },
+ defaultVariants: {
+ variant: 'primary',
+ fullWidth: false,
+ },
+});
diff --git a/packages/ui/src/ceramic/text.ts b/packages/ui/src/ceramic/text.ts
new file mode 100644
index 00000000000..f7442050ba5
--- /dev/null
+++ b/packages/ui/src/ceramic/text.ts
@@ -0,0 +1,48 @@
+import { style, variants } from './variants';
+
+export const textStyles = variants({
+ base: style(theme => ({
+ boxSizing: 'border-box',
+ padding: 0,
+ margin: 0,
+ background: 'none',
+ border: 'none',
+ fontFamily: theme.fontFamilies.sans,
+ textDecoration: 'none',
+ ...theme.fontRendering,
+ })),
+ variants: {
+ variant: {
+ 'heading-1': style(theme => ({ ...theme.typography.heading[1] })),
+ 'heading-2': style(theme => ({ ...theme.typography.heading[2] })),
+ 'heading-3': style(theme => ({ ...theme.typography.heading[3] })),
+ 'heading-4': style(theme => ({ ...theme.typography.heading[4] })),
+ 'heading-5': style(theme => ({ ...theme.typography.heading[5] })),
+ 'heading-6': style(theme => ({ ...theme.typography.heading[6] })),
+ 'label-1': style(theme => ({ ...theme.typography.label[1] })),
+ 'label-2': style(theme => ({ ...theme.typography.label[2] })),
+ 'label-3': style(theme => ({ ...theme.typography.label[3] })),
+ 'label-4': style(theme => ({ ...theme.typography.label[4] })),
+ 'label-5': style(theme => ({ ...theme.typography.label[5] })),
+ 'body-1': style(theme => ({ ...theme.typography.body[1] })),
+ 'body-2': style(theme => ({ ...theme.typography.body[2] })),
+ 'body-3': style(theme => ({ ...theme.typography.body[3] })),
+ 'body-4': style(theme => ({ ...theme.typography.body[4] })),
+ },
+ color: {
+ default: style(theme => ({ color: theme.colors.primary })),
+ muted: style(theme => ({ color: theme.colors.secondary })),
+ subtle: style(theme => ({ color: theme.colors.dimmed })),
+ accent: style(theme => ({ color: theme.colors.brand })),
+ },
+ font: {
+ sans: style(theme => ({ fontFamily: theme.fontFamilies.sans })),
+ mono: style(theme => ({ fontFamily: theme.fontFamilies.mono })),
+ },
+ },
+ defaultVariants: {
+ variant: 'body-2',
+ color: 'default',
+ font: 'sans',
+ },
+});
diff --git a/packages/ui/src/ceramic/theme-provider.tsx b/packages/ui/src/ceramic/theme-provider.tsx
new file mode 100644
index 00000000000..ddebc749b9c
--- /dev/null
+++ b/packages/ui/src/ceramic/theme-provider.tsx
@@ -0,0 +1,19 @@
+// eslint-disable-next-line no-restricted-imports
+import createCache from '@emotion/cache';
+// eslint-disable-next-line no-restricted-imports
+import { CacheProvider, ThemeContext } from '@emotion/react';
+import React from 'react';
+
+import { type CeramicTheme, ceramicTheme } from './theme';
+
+const ceramicCache = createCache({ key: 'ceramic' });
+
+export const CeramicThemeProvider = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const useCeramicTheme = () => React.useContext(ThemeContext) as CeramicTheme;
diff --git a/packages/ui/src/ceramic/theme.ts b/packages/ui/src/ceramic/theme.ts
new file mode 100644
index 00000000000..4a25a2a087c
--- /dev/null
+++ b/packages/ui/src/ceramic/theme.ts
@@ -0,0 +1,296 @@
+// Ceramic Colors (from CSS variables)
+const gray = {
+ 50: '#fafafb',
+ 100: '#f6f6f7',
+ 200: '#ececee',
+ 300: '#dbdbe0',
+ 400: '#c7c7cf',
+ 500: '#adadb7',
+ 600: '#90909d',
+ 700: '#767684',
+ 800: '#5f5f6f',
+ 900: '#4c4c5c',
+ 1000: '#3d3d4a',
+ 1100: '#33333e',
+ 1200: '#2b2b34',
+ 1300: '#232328',
+ 1400: '#1b1b1f',
+ 1500: '#111113',
+} as const;
+
+const purple = {
+ 50: '#f5f3ff',
+ 100: '#e3e0ff',
+ 200: '#ccc8ff',
+ 300: '#bab0ff',
+ 400: '#a698ff',
+ 500: '#9280ff',
+ 600: '#846bff',
+ 700: '#6c47ff',
+ 800: '#5f15fe',
+ 900: '#4d06d1',
+ 1000: '#3707a6',
+ 1100: '#27057c',
+ 1200: '#1c045f',
+ 1300: '#16034b',
+} as const;
+
+const green = {
+ 50: '#effdf1',
+ 100: '#aff9bf',
+ 200: '#65f088',
+ 300: '#49dc6e',
+ 400: '#31c854',
+ 500: '#1eb43c',
+ 600: '#199d34',
+ 700: '#15892b',
+ 800: '#107524',
+ 900: '#09661c',
+ 1000: '#0b5619',
+ 1100: '#0c4919',
+ 1200: '#0c3c18',
+ 1300: '#053211',
+} as const;
+
+const red = {
+ 50: '#fef8f8',
+ 100: '#fedddd',
+ 200: '#fec4c4',
+ 300: '#fca9a9',
+ 400: '#f98a8a',
+ 500: '#f86969',
+ 600: '#f73d3d',
+ 700: '#e02e2e',
+ 800: '#c22a2a',
+ 900: '#aa1b1b',
+ 1000: '#921414',
+ 1100: '#7a1313',
+ 1200: '#651414',
+ 1300: '#550e0e',
+ 1400: '#3d0101',
+ 1500: '#2d0101',
+} as const;
+
+const orange = {
+ 50: '#fff8f2',
+ 100: '#ffe4c4',
+ 200: '#fecc9f',
+ 300: '#feb166',
+ 400: '#fd9357',
+ 500: '#fd7224',
+ 600: '#e06213',
+ 700: '#c3540f',
+ 800: '#a8470c',
+ 900: '#9d3405',
+ 1000: '#8a2706',
+ 1100: '#75220b',
+ 1200: '#5f1e0c',
+ 1300: '#50170a',
+} as const;
+
+const yellow = {
+ 50: '#fefbdc',
+ 100: '#f7ed55',
+ 200: '#e5d538',
+ 300: '#d7be35',
+ 400: '#c0aa18',
+ 500: '#bd9005',
+ 600: '#a47c04',
+ 700: '#8d6b03',
+ 800: '#775902',
+ 900: '#674401',
+ 1000: '#563202',
+ 1100: '#412303',
+ 1200: '#321904',
+ 1300: '#2a1203',
+} as const;
+
+const blue = {
+ 50: '#f6faff',
+ 100: '#daeafe',
+ 200: '#b4d5fe',
+ 300: '#8dc2fd',
+ 400: '#73acfa',
+ 500: '#6694f8',
+ 600: '#307ff6',
+ 700: '#236dd7',
+ 800: '#1c5bb6',
+ 900: '#1744a6',
+ 1000: '#0f318e',
+ 1100: '#0e2369',
+ 1200: '#0b1c49',
+ 1300: '#0c1637',
+} as const;
+
+// Typography - Labels
+const label = {
+ 1: { fontSize: '1rem', lineHeight: '1.375rem', fontWeight: 500 },
+ 2: { fontSize: '0.875rem', lineHeight: '1.25rem', fontWeight: 500 },
+ 3: { fontSize: '0.75rem', lineHeight: '1rem', fontWeight: 500 },
+ 4: {
+ fontSize: '0.6875rem',
+ lineHeight: '0.875rem',
+ fontWeight: 500,
+ letterSpacing: '0.015em',
+ },
+ 5: { fontSize: '0.625rem', lineHeight: '0.8125rem', fontWeight: 500 },
+} as const;
+
+// Typography - Headings
+const heading = {
+ 1: {
+ fontSize: '2.25rem',
+ lineHeight: '2.5rem',
+ fontWeight: 500,
+ letterSpacing: '-0.02em',
+ },
+ 2: {
+ fontSize: '2rem',
+ lineHeight: '2.25rem',
+ fontWeight: 500,
+ letterSpacing: '-0.02em',
+ },
+ 3: {
+ fontSize: '1.75rem',
+ lineHeight: '2.125rem',
+ fontWeight: 500,
+ letterSpacing: '-0.015em',
+ },
+ 4: {
+ fontSize: '1.5rem',
+ lineHeight: '2rem',
+ fontWeight: 500,
+ letterSpacing: '-0.01em',
+ },
+ 5: {
+ fontSize: '1.25rem',
+ lineHeight: '1.75rem',
+ fontWeight: 500,
+ letterSpacing: '-0.01em',
+ },
+ 6: { fontSize: '1.0625rem', lineHeight: '1.5rem', fontWeight: 500 },
+} as const;
+
+// Typography - Body
+const body = {
+ 1: { fontSize: '1rem', lineHeight: '1.375rem', fontWeight: 400 },
+ 2: { fontSize: '0.875rem', lineHeight: '1.25rem', fontWeight: 400 },
+ 3: {
+ fontSize: '0.75rem',
+ lineHeight: '1rem',
+ fontWeight: 400,
+ letterSpacing: '0.01em',
+ },
+ 4: { fontSize: '0.6875rem', lineHeight: '0.875rem', fontWeight: 400 },
+} as const;
+
+const fontWeights = {
+ normal: 400,
+ medium: 500,
+ semibold: 600,
+ bold: 700,
+} as const;
+
+const fontFamilies = {
+ sans: 'system-ui, sans-serif',
+ mono: 'ui-monospace, monospace',
+} as const;
+
+// Spacing (Tailwind-compatible scale, 1 unit = 0.25rem = 4px)
+const spacing = {
+ 0: '0',
+ px: '1px',
+ 0.5: '0.125rem',
+ 1: '0.25rem',
+ 1.5: '0.375rem',
+ 2: '0.5rem',
+ 2.5: '0.625rem',
+ 3: '0.75rem',
+ 3.5: '0.875rem',
+ 4: '1rem',
+ 5: '1.25rem',
+ 6: '1.5rem',
+ 7: '1.75rem',
+ 8: '2rem',
+ 9: '2.25rem',
+ 10: '2.5rem',
+ 11: '2.75rem',
+ 12: '3rem',
+ 14: '3.5rem',
+ 16: '4rem',
+ 20: '5rem',
+ 24: '6rem',
+ 28: '7rem',
+ 32: '8rem',
+ 36: '9rem',
+ 40: '10rem',
+ 44: '11rem',
+ 48: '12rem',
+ 52: '13rem',
+ 56: '14rem',
+ 60: '15rem',
+ 64: '16rem',
+ 72: '18rem',
+ 80: '20rem',
+ 96: '24rem',
+} as const;
+
+/**
+ * Creates a transparent color using color-mix().
+ * Browser support: Chrome 111+, Firefox 113+, Safari 16.4+
+ * For older browsers, consider using rgba() fallback or CSS @supports queries.
+ */
+const alpha = (color: string, opacity: number) => `color-mix(in srgb, ${color} ${opacity}%, transparent)`;
+const negative = (value: string) => `-${value}`;
+
+const baseTheme = {
+ colors: {
+ gray,
+ purple,
+ green,
+ red,
+ orange,
+ yellow,
+ blue,
+ white: '#fff',
+ black: '#000',
+ // Semantic tokens (using dark palette values)
+ primary: gray[100],
+ secondary: gray[500],
+ dimmed: gray[800],
+ positive: green[400],
+ negative: red[600],
+ warning: orange[500],
+ brand: purple[600],
+ info: blue[600],
+ },
+ typography: {
+ label,
+ heading,
+ body,
+ },
+ fontWeights,
+ fontFamilies,
+ spacing,
+ alpha,
+ negative,
+} as const;
+
+const fontRendering = {
+ WebkitFontSmoothing: 'auto',
+ MozOsxFontSmoothing: 'auto',
+ textRendering: 'auto',
+ WebkitTextSizeAdjust: '100%',
+ textSizeAdjust: '100%',
+} as const;
+
+export const ceramicTheme = {
+ ...baseTheme,
+ focusRing: () => ({
+ outline: `2px solid ${baseTheme.colors.purple[700]}`,
+ outlineOffset: '2px',
+ }),
+ fontRendering,
+} as const;
+
+export type CeramicTheme = typeof ceramicTheme;
diff --git a/packages/ui/src/ceramic/variants.test.ts b/packages/ui/src/ceramic/variants.test.ts
new file mode 100644
index 00000000000..65a9b136f93
--- /dev/null
+++ b/packages/ui/src/ceramic/variants.test.ts
@@ -0,0 +1,380 @@
+// eslint-disable-next-line no-restricted-imports
+import type { Interpolation, Theme } from '@emotion/react';
+import { describe, expect, it } from 'vitest';
+
+import { ceramicTheme } from './theme';
+import { style, variants } from './variants';
+
+// Helper to extract the CSS object from Interpolation
+function getStyles(interpolation: Interpolation): Record {
+ if (typeof interpolation === 'function') {
+ return interpolation(ceramicTheme as unknown as Theme) as Record;
+ }
+ if (interpolation == null) {
+ return {};
+ }
+ return interpolation as Record;
+}
+
+describe('variants', () => {
+ it('applies base styles', () => {
+ const buttonStyles = variants({
+ base: { padding: '1rem', margin: '0.5rem' },
+ variants: {},
+ });
+
+ const result = getStyles(buttonStyles());
+ expect(result).toEqual({ padding: '1rem', margin: '0.5rem' });
+ });
+
+ it('applies base styles from function', () => {
+ const buttonStyles = variants({
+ base: style(theme => ({
+ padding: theme.spacing[4],
+ color: theme.colors.white,
+ })),
+ variants: {},
+ });
+
+ const result = getStyles(buttonStyles());
+ expect(result).toEqual({
+ padding: ceramicTheme.spacing[4],
+ color: ceramicTheme.colors.white,
+ });
+ });
+
+ it('applies variant styles based on props', () => {
+ const buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem', padding: '0.5rem' },
+ md: { fontSize: '1rem', padding: '1rem' },
+ lg: { fontSize: '1.25rem', padding: '1.5rem' },
+ },
+ },
+ });
+
+ const result = getStyles(buttonStyles({ size: 'sm' }));
+ expect(result).toEqual({ fontSize: '0.75rem', padding: '0.5rem' });
+ });
+
+ it('applies variant styles from function', () => {
+ const buttonStyles = variants({
+ variants: {
+ variant: {
+ primary: style(theme => ({
+ background: theme.colors.purple[700],
+ color: theme.colors.white,
+ })),
+ secondary: style(theme => ({
+ background: theme.colors.gray[200],
+ color: theme.colors.gray[1200],
+ })),
+ },
+ },
+ });
+
+ const result = getStyles(buttonStyles({ variant: 'primary' }));
+ expect(result).toEqual({
+ background: ceramicTheme.colors.purple[700],
+ color: ceramicTheme.colors.white,
+ });
+ });
+
+ it('applies multiple variants', () => {
+ const buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ variant: {
+ primary: { background: 'blue' },
+ secondary: { background: 'gray' },
+ },
+ },
+ });
+
+ const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' }));
+ expect(result).toEqual({ fontSize: '0.75rem', background: 'blue' });
+ });
+
+ it('applies default variants when props are not provided', () => {
+ const buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ variant: {
+ primary: { background: 'blue' },
+ secondary: { background: 'gray' },
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ variant: 'primary',
+ },
+ });
+
+ const result = getStyles(buttonStyles());
+ expect(result).toEqual({ fontSize: '1rem', background: 'blue' });
+ });
+
+ it('overrides default variants with props', () => {
+ const buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+ });
+
+ const result = getStyles(buttonStyles({ size: 'sm' }));
+ expect(result).toEqual({ fontSize: '0.75rem' });
+ });
+
+ it('applies boolean variants with true/false keys', () => {
+ const buttonStyles = variants({
+ variants: {
+ fullWidth: {
+ true: { width: '100%' },
+ false: { width: 'fit-content' },
+ },
+ disabled: {
+ true: { opacity: 0.5, cursor: 'not-allowed' },
+ false: { opacity: 1, cursor: 'pointer' },
+ },
+ },
+ });
+
+ const result = getStyles(buttonStyles({ fullWidth: true, disabled: false }));
+ expect(result).toEqual({ width: '100%', opacity: 1, cursor: 'pointer' });
+ });
+
+ it('applies boolean variants from default variants', () => {
+ const buttonStyles = variants({
+ variants: {
+ fullWidth: {
+ true: { width: '100%' },
+ false: { width: 'fit-content' },
+ },
+ },
+ defaultVariants: {
+ fullWidth: false,
+ },
+ });
+
+ const result = getStyles(buttonStyles());
+ expect(result).toEqual({ width: 'fit-content' });
+ });
+
+ it('applies compound variants when conditions match', () => {
+ const buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ variant: {
+ primary: { background: 'blue' },
+ secondary: { background: 'gray' },
+ },
+ },
+ compoundVariants: [
+ {
+ condition: { size: 'sm', variant: 'primary' },
+ styles: { borderRadius: '0.25rem' },
+ },
+ {
+ condition: { size: 'md', variant: 'secondary' },
+ styles: { borderRadius: '0.5rem' },
+ },
+ ],
+ });
+
+ const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' }));
+ expect(result).toEqual({
+ fontSize: '0.75rem',
+ background: 'blue',
+ borderRadius: '0.25rem',
+ });
+ });
+
+ it('applies compound variants with function styles', () => {
+ const buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ variant: {
+ primary: { background: 'blue' },
+ },
+ },
+ compoundVariants: [
+ {
+ condition: { size: 'sm', variant: 'primary' },
+ styles: style(theme => ({
+ border: `1px solid ${theme.colors.purple[700]}`,
+ })),
+ },
+ ],
+ });
+
+ const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' }));
+ expect(result).toEqual({
+ fontSize: '0.75rem',
+ background: 'blue',
+ border: `1px solid ${ceramicTheme.colors.purple[700]}`,
+ });
+ });
+
+ it('does not apply compound variants when conditions do not match', () => {
+ const buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ variant: {
+ primary: { background: 'blue' },
+ },
+ },
+ compoundVariants: [
+ {
+ condition: { size: 'sm', variant: 'primary' },
+ styles: { borderRadius: '0.25rem' },
+ },
+ ],
+ });
+
+ const result = getStyles(buttonStyles({ size: 'md', variant: 'primary' }));
+ expect(result).toEqual({
+ fontSize: '1rem',
+ background: 'blue',
+ });
+ });
+
+ it('merges base styles with variant styles', () => {
+ const buttonStyles = variants({
+ base: { padding: '1rem', margin: '0.5rem' },
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ },
+ });
+
+ const result = getStyles(buttonStyles({ size: 'sm' }));
+ expect(result).toEqual({
+ padding: '1rem',
+ margin: '0.5rem',
+ fontSize: '0.75rem',
+ });
+ });
+
+ it('merges styles with later variants overriding earlier ones', () => {
+ const buttonStyles = variants({
+ base: { fontSize: '1rem' },
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ },
+ },
+ });
+
+ const result = getStyles(buttonStyles({ size: 'sm' }));
+ expect(result).toEqual({ fontSize: '0.75rem' });
+ });
+
+ it('handles nested pseudo-selectors', () => {
+ const buttonStyles = variants({
+ base: {
+ '&:hover': { opacity: 0.8 },
+ '&:active': { opacity: 0.6 },
+ },
+ variants: {
+ variant: {
+ primary: {
+ '&:hover': { background: 'darkblue' },
+ },
+ },
+ },
+ });
+
+ const result = getStyles(buttonStyles({ variant: 'primary' }));
+ expect(result).toEqual({
+ '&:hover': { opacity: 0.8, background: 'darkblue' },
+ '&:active': { opacity: 0.6 },
+ });
+ });
+
+ it('handles null and undefined props', () => {
+ const buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+ });
+
+ const result1 = getStyles(buttonStyles({ size: null as any }));
+ const result2 = getStyles(buttonStyles({ size: undefined as any }));
+ expect(result1).toEqual({ fontSize: '1rem' });
+ expect(result2).toEqual({ fontSize: '1rem' });
+ });
+
+ it('handles empty variants object', () => {
+ const buttonStyles = variants({
+ base: { padding: '1rem' },
+ variants: {},
+ });
+
+ const result = getStyles(buttonStyles());
+ expect(result).toEqual({ padding: '1rem' });
+ });
+
+ it('handles missing variant definition gracefully', () => {
+ const buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ },
+ },
+ });
+
+ const result = getStyles(buttonStyles({ size: 'nonexistent' as any }));
+ expect(result).toEqual({});
+ });
+});
+
+describe('style', () => {
+ it('returns the function unchanged', () => {
+ const fn = (theme: typeof ceramicTheme) => ({ color: theme.colors.white });
+ const result = style(fn);
+ expect(result).toBe(fn);
+ });
+
+ it('provides CeramicTheme typing', () => {
+ const fn = style(theme => ({
+ color: theme.colors.white,
+ padding: theme.spacing[4],
+ }));
+
+ const result = fn(ceramicTheme);
+ expect(result).toEqual({
+ color: ceramicTheme.colors.white,
+ padding: ceramicTheme.spacing[4],
+ });
+ });
+});
diff --git a/packages/ui/src/ceramic/variants.ts b/packages/ui/src/ceramic/variants.ts
new file mode 100644
index 00000000000..99a75721dc7
--- /dev/null
+++ b/packages/ui/src/ceramic/variants.ts
@@ -0,0 +1,183 @@
+import { fastDeepMergeAndReplace } from '@clerk/shared/utils';
+// eslint-disable-next-line no-restricted-imports
+import type { Interpolation, Theme } from '@emotion/react';
+
+import { type CeramicTheme } from './theme';
+
+type CSSObject = Record;
+// StyleFunction uses CeramicTheme to provide proper typing for theme parameter
+type StyleFunction = (theme: CeramicTheme) => CSSObject;
+type StyleRule = CSSObject | StyleFunction;
+
+// Convert string literal "true" | "false" to boolean (CVA's StringToBoolean)
+type StringToBoolean = T extends 'true' | 'false' ? boolean : T;
+
+/**
+ * Extracts variant props type from a variant style function.
+ *
+ * @example
+ * ```ts
+ * const buttonStyles = variants({
+ * variants: {
+ * size: { sm: {...}, md: {...} },
+ * variant: { primary: {...}, secondary: {...} },
+ * },
+ * });
+ *
+ * type ButtonProps = VariantProps;
+ * // { size?: 'sm' | 'md' | null | undefined; variant?: 'primary' | 'secondary' | null | undefined }
+ *
+ * function Button(props: ButtonProps) {
+ * return ;
+ * }
+ * ```
+ */
+export type VariantProps any> = Parameters[0];
+
+// Maps variant names to their allowed values (with boolean conversion)
+// This is the key type that allows boolean values for variants with true/false keys
+type ConfigVariants = {
+ [K in keyof T]?: StringToBoolean | null | undefined;
+};
+
+// Config type - no constraint on T to preserve literal types
+// NoInfer prevents TypeScript from using defaultVariants/compoundVariants for T inference
+type VariantsConfig = {
+ base?: StyleRule;
+ variants?: T;
+ defaultVariants?: ConfigVariants>;
+ compoundVariants?: Array<{
+ condition: ConfigVariants>;
+ styles?: StyleRule;
+ }>;
+};
+
+/**
+ * Identity function that provides CeramicTheme typing for style functions.
+ * Use this to get autocomplete and type checking for theme properties.
+ *
+ * @example
+ * ```ts
+ * const buttonStyles = variants({
+ * base: style(theme => ({ padding: theme.spacing[2] })),
+ * variants: {
+ * variant: {
+ * primary: style(theme => ({ background: theme.colors.purple[700] })),
+ * },
+ * },
+ * });
+ * ```
+ */
+export const style = (fn: StyleFunction): StyleFunction => fn;
+
+function resolveStyleRule(rule: StyleRule | undefined, theme: CeramicTheme): CSSObject {
+ if (!rule) {
+ return {};
+ }
+ if (typeof rule === 'function') {
+ return rule(theme);
+ }
+ return rule;
+}
+
+function normalizeVariantValue(value: unknown): string {
+ return String(value);
+}
+
+function calculateVariantsToBeApplied(
+ variants: Record>,
+ props: Record,
+ defaultVariants: Record,
+): Record {
+ const variantsToApply: Record = {};
+ for (const key in variants) {
+ const value = props[key] ?? defaultVariants[key];
+ if (value != null) {
+ variantsToApply[key] = value;
+ }
+ }
+ return variantsToApply;
+}
+
+function conditionMatches(
+ compoundVariant: { condition: Record; styles?: StyleRule },
+ variantsToApply: Record,
+): boolean {
+ const { condition } = compoundVariant;
+ for (const key in condition) {
+ if (condition[key] !== variantsToApply[key]) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Creates a variant-based style function for Emotion CSS objects.
+ * Similar to CVA but works with CSS-in-JS instead of class names.
+ *
+ * @example
+ * ```ts
+ * const buttonStyles = variants({
+ * base: (theme) => ({ padding: theme.spacing[2] }),
+ * variants: {
+ * variant: {
+ * primary: (theme) => ({ background: theme.colors.purple[700] }),
+ * secondary: (theme) => ({ background: theme.colors.gray[1200] }),
+ * },
+ * size: {
+ * sm: { fontSize: '0.75rem' },
+ * md: { fontSize: '1rem' },
+ * },
+ * },
+ * defaultVariants: {
+ * variant: 'primary',
+ * size: 'md',
+ * },
+ * });
+ *
+ * // Usage:
+ *
+ * ```
+ */
+export function variants(config: VariantsConfig) {
+ const { base, variants: variantDefinitions = {} as T, defaultVariants = {}, compoundVariants = [] } = config;
+
+ return (props: ConfigVariants = {}): Interpolation => {
+ return ((theme: Theme) => {
+ const ceramicTheme = theme as unknown as CeramicTheme;
+ const computedStyles: CSSObject = {};
+ const variantDefs = variantDefinitions as Record>;
+
+ const baseStyles = resolveStyleRule(base, ceramicTheme);
+ if (baseStyles && typeof baseStyles === 'object') {
+ fastDeepMergeAndReplace(baseStyles, computedStyles);
+ }
+
+ const variantsToApply = calculateVariantsToBeApplied(
+ variantDefs,
+ props as Record,
+ defaultVariants as Record,
+ );
+
+ for (const variantKey in variantsToApply) {
+ const variantValue = variantsToApply[variantKey];
+ const variantDef = variantDefs[variantKey];
+ const normalizedValue = normalizeVariantValue(variantValue);
+ if (variantDef && normalizedValue in variantDef) {
+ const variantStyle = resolveStyleRule(variantDef[normalizedValue], ceramicTheme);
+ fastDeepMergeAndReplace(variantStyle, computedStyles);
+ }
+ }
+
+ for (const compoundVariant of compoundVariants) {
+ if (conditionMatches(compoundVariant, variantsToApply)) {
+ const compoundStyles = resolveStyleRule(compoundVariant.styles, ceramicTheme);
+ fastDeepMergeAndReplace(compoundStyles, computedStyles);
+ }
+ }
+
+ return computedStyles;
+ }) as unknown as Interpolation;
+ };
+}
diff --git a/packages/ui/src/ceramic/variants.type.test.ts b/packages/ui/src/ceramic/variants.type.test.ts
new file mode 100644
index 00000000000..44c0bd5da36
--- /dev/null
+++ b/packages/ui/src/ceramic/variants.type.test.ts
@@ -0,0 +1,253 @@
+import { describe, expectTypeOf, it } from 'vitest';
+
+import type { CeramicTheme } from './theme';
+import { style, variants } from './variants';
+
+describe('variants type tests', () => {
+ describe('boolean variants', () => {
+ it('allows boolean values in defaultVariants for true/false keys', () => {
+ const _buttonStyles = variants({
+ variants: {
+ fullWidth: {
+ true: { width: '100%' },
+ false: { width: 'fit-content' },
+ },
+ },
+ defaultVariants: {
+ fullWidth: false,
+ },
+ });
+
+ type Props = Parameters[0];
+ expectTypeOf<{ fullWidth: false }>().toMatchTypeOf();
+ expectTypeOf<{ fullWidth: true }>().toMatchTypeOf();
+ });
+
+ it('allows boolean values in props for true/false keys', () => {
+ const _buttonStyles = variants({
+ variants: {
+ disabled: {
+ true: { opacity: 0.5 },
+ false: { opacity: 1 },
+ },
+ },
+ });
+
+ type Props = Parameters[0];
+ expectTypeOf<{ disabled: true }>().toMatchTypeOf();
+ expectTypeOf<{ disabled: false }>().toMatchTypeOf();
+ });
+
+ it('does not allow string values for boolean variants', () => {
+ const _buttonStyles = variants({
+ variants: {
+ fullWidth: {
+ true: { width: '100%' },
+ false: { width: 'fit-content' },
+ },
+ },
+ });
+
+ type Props = Parameters[0];
+ expectTypeOf<{ fullWidth: 'true' }>().not.toMatchTypeOf();
+ });
+ });
+
+ describe('string variants', () => {
+ it('allows string literal values in defaultVariants', () => {
+ const _buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ lg: { fontSize: '1.25rem' },
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+ });
+
+ type Props = Parameters[0];
+ expectTypeOf<{ size: 'sm' }>().toMatchTypeOf();
+ expectTypeOf<{ size: 'md' }>().toMatchTypeOf();
+ expectTypeOf<{ size: 'lg' }>().toMatchTypeOf();
+ });
+
+ it('does not allow invalid string values', () => {
+ const _buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ },
+ });
+
+ type Props = Parameters[0];
+ expectTypeOf<{ size: 'xl' }>().not.toMatchTypeOf();
+ });
+ });
+
+ describe('mixed variants', () => {
+ it('allows mixing boolean and string variants', () => {
+ const _buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ fullWidth: {
+ true: { width: '100%' },
+ false: { width: 'fit-content' },
+ },
+ },
+ defaultVariants: {
+ size: 'sm',
+ fullWidth: false,
+ },
+ });
+
+ type Props = Parameters[0];
+ expectTypeOf<{ size: 'md'; fullWidth: true }>().toMatchTypeOf();
+ });
+ });
+
+ describe('style helper', () => {
+ it('provides CeramicTheme typing for theme parameter', () => {
+ const fn = style(theme => {
+ // Theme parameter should be typed as CeramicTheme
+ return { color: theme.colors.white };
+ });
+
+ expectTypeOf(fn).toBeFunction();
+ expectTypeOf[0]>().toEqualTypeOf();
+ });
+
+ it('returns StyleFunction type', () => {
+ const _fn = style(theme => ({ color: theme.colors.white }));
+ type Return = ReturnType;
+ expectTypeOf().toMatchTypeOf>();
+ });
+ });
+
+ describe('props type inference', () => {
+ it('infers correct props type from variants', () => {
+ const _buttonStyles = variants({
+ variants: {
+ variant: {
+ primary: { background: 'blue' },
+ secondary: { background: 'gray' },
+ },
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ },
+ });
+
+ type Props = Parameters[0];
+ // Props should allow optional variant and size
+ expectTypeOf<{ variant?: 'primary' | 'secondary'; size?: 'sm' | 'md' }>().toMatchTypeOf();
+ });
+
+ it('allows partial props', () => {
+ const _buttonStyles = variants({
+ variants: {
+ variant: {
+ primary: { background: 'blue' },
+ secondary: { background: 'gray' },
+ },
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ },
+ });
+
+ type Props = Parameters[0];
+ expectTypeOf<{ variant: 'primary' }>().toMatchTypeOf();
+ expectTypeOf<{ size: 'md' }>().toMatchTypeOf();
+ expectTypeOf>().toMatchTypeOf();
+ });
+
+ it('allows null and undefined in props', () => {
+ const _buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ },
+ });
+
+ type Props = Parameters[0];
+ expectTypeOf<{ size: null }>().toMatchTypeOf();
+ expectTypeOf<{ size: undefined }>().toMatchTypeOf();
+ });
+ });
+
+ describe('compound variants', () => {
+ it('allows compound variants with correct condition types', () => {
+ const _buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ md: { fontSize: '1rem' },
+ },
+ variant: {
+ primary: { background: 'blue' },
+ secondary: { background: 'gray' },
+ },
+ },
+ compoundVariants: [
+ {
+ condition: { size: 'sm', variant: 'primary' },
+ styles: { borderRadius: '0.25rem' },
+ },
+ ],
+ });
+
+ // Type should compile without errors
+ expectTypeOf(_buttonStyles).toBeFunction();
+ });
+
+ it('allows boolean values in compound variant conditions', () => {
+ const _buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ },
+ fullWidth: {
+ true: { width: '100%' },
+ false: { width: 'fit-content' },
+ },
+ },
+ compoundVariants: [
+ {
+ condition: { size: 'sm', fullWidth: true },
+ styles: { padding: '0.5rem' },
+ },
+ ],
+ });
+
+ // Type should compile without errors
+ expectTypeOf(_buttonStyles).toBeFunction();
+ });
+ });
+
+ describe('return type', () => {
+ it('returns Interpolation', () => {
+ const _buttonStyles = variants({
+ variants: {
+ size: {
+ sm: { fontSize: '0.75rem' },
+ },
+ },
+ });
+
+ // Verify the function can be called with props
+ expectTypeOf(_buttonStyles({ size: 'sm' })).toMatchTypeOf();
+ });
+ });
+});