diff --git a/CLAUDE.md b/CLAUDE.md
index 645017e..6860954 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -24,7 +24,9 @@ CSS is hostile to humans at scale: global namespace, no markup-to-definition lin
**Parts** - Sub-components within a component (e.g., Header, Body, Footer)
- Defined via `parts: { PartName: { ...styles } }`
- Accessible in render as `parts.PartName`
+- Also accessible as static properties: `Component.PartName` (no custom render needed)
- Generate classes like `ComponentName_PartName`
+- Variant part styles generate dual CSS: direct class (`.Part.variant`) and descendant (`.Root.variant .Part`)
**Variants** - Conditional styling based on props
- Boolean: `variants: { active: { color: 'blue' } }` → ``
diff --git a/README.md b/README.md
index e367455..b68524a 100644
--- a/README.md
+++ b/README.md
@@ -118,6 +118,43 @@ console.log()
console.log()
```
+### parts + `Component.Part`
+
+Parts are also available as static properties on the component, so you
+can compose without writing a custom `render()`:
+
+```tsx
+const Card = define("Card", {
+ padding: 20,
+ background: "#111",
+
+ parts: {
+ Title: { base: "h2", fontSize: 24 },
+ Body: { color: "gray" },
+ },
+
+ variants: {
+ dark: {
+ background: "black",
+ parts: {
+ Body: { color: "white" },
+ },
+ },
+ },
+})
+
+// Usage
+
+ Hello
+ World
+
+
+
+ Dark mode
+ Variant styles reach parts automatically
+
+```
+
### selectors
Use `selectors` to write custom CSS selectors. Reference the current
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index be6fc79..2244aea 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -416,6 +416,79 @@ Generated CSS classes:
.Card_Footer { border-top: 1px solid #333; font-size: 12px; margin-top: 16px; padding-top: 12px; }
```
+### Static part access (`Component.Part`)
+
+Parts are also available as static properties on the component itself. This lets you compose components without writing a custom `render` function:
+
+```tsx
+const Card = define('Card', {
+ padding: 20,
+ background: '#111',
+
+ parts: {
+ Title: { base: 'h2', fontSize: 24 },
+ Body: { padding: 10, color: '#888' },
+ },
+})
+
+// Use parts directly as Component.Part
+
+ Hello
+ World
+
+```
+
+This renders:
+
+```html
+
+```
+
+When a variant is set on the parent, descendant CSS selectors ensure the variant styles reach the static parts automatically:
+
+```tsx
+const Tabs = define('Tabs', {
+ display: 'flex',
+
+ parts: {
+ Tab: { base: 'button', color: 'gray' },
+ },
+
+ variants: {
+ active: {
+ parts: {
+ Tab: { color: 'blue' },
+ },
+ },
+ },
+})
+
+// Variant on parent styles the child parts via descendant selectors
+
+ Home
+ Settings
+
+```
+
+Generated CSS (both selectors for both usage patterns):
+
+```css
+/* For render({ parts }) path — direct class on part */
+.Tabs_Tab.active { color: blue; }
+/* For Component.Part path — descendant of variant root */
+.Tabs.active .Tabs_Tab { color: blue; }
+```
+
+Static parts can also receive variant props directly for per-instance control:
+
+```tsx
+Home
+Settings
+```
+
### Parts with states
Parts can have their own pseudo-selectors:
diff --git a/examples/landing.tsx b/examples/landing.tsx
index 7f54513..779770e 100644
--- a/examples/landing.tsx
+++ b/examples/landing.tsx
@@ -426,6 +426,43 @@ console.log()
console.log()
\`\`\`
+### parts + \`Component.Part\`
+
+Parts are also available as static properties on the component, so you
+can compose without writing a custom \`render()\`:
+
+\`\`\`tsx
+const Card = define("Card", {
+ padding: 20,
+ background: "#111",
+
+ parts: {
+ Title: { base: "h2", fontSize: 24 },
+ Body: { color: "gray" },
+ },
+
+ variants: {
+ dark: {
+ background: "black",
+ parts: {
+ Body: { color: "white" },
+ },
+ },
+ },
+})
+
+// Usage
+
+ Hello
+ World
+
+
+
+ Dark mode
+ Variant styles reach parts automatically
+
+\`\`\`
+
### selectors
Use \`selectors\` to write custom CSS selectors. Reference the current
diff --git a/src/index.tsx b/src/index.tsx
index 820aeb5..ac06e5f 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -245,8 +245,12 @@ function registerStyles(name: string, def: TagDef) {
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
const basePartClassName = makeClassName(name, partName)
+ // Direct class on part (for render({ parts }) path where parts inherit variant props)
const partClassName = `${basePartClassName}.${variantName}`
registerClassStyles(name, partClassName, partDef)
+ // Descendant selector (for Component.Part path where root has variant class)
+ const descendantClassName = `${baseClassName}.${variantName} .${basePartClassName}`
+ registerClassStyles(name, descendantClassName, partDef)
}
} else {
// Keyed variant - iterate over the keys
@@ -255,8 +259,13 @@ function registerStyles(name: string, def: TagDef) {
registerClassStyles(name, className, variantDef)
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
+ const basePartClassName = makeClassName(name, partName)
+ // Direct class on part (for render({ parts }) path)
const partClassName = makeClassName(name, partName, variantName, variantKey)
registerClassStyles(name, partClassName, partDef)
+ // Descendant selector (for Component.Part path)
+ const descendantClassName = `${className} .${basePartClassName}`
+ registerClassStyles(name, descendantClassName, partDef)
}
}
}
@@ -289,7 +298,7 @@ export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) {
const currentProps: Record = {}
const Root = makeComponent(name, def, currentProps)
- return (props: Record) => {
+ const component = (props: Record) => {
for (const key in currentProps) delete currentProps[key]
Object.assign(currentProps, props)
const parts: Record = { Root }
@@ -299,6 +308,12 @@ export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) {
return def.render?.({ props, parts }) ?? {props.children}
}
+
+ // Attach parts as static properties for Component.Part syntax
+ for (const [part] of Object.entries(def.parts ?? {}))
+ (component as any)[part] = makeComponent(name, def, {}, part)
+
+ return component
}
// automatic names
diff --git a/src/tests/index.test.tsx b/src/tests/index.test.tsx
index bff980b..24a8358 100644
--- a/src/tests/index.test.tsx
+++ b/src/tests/index.test.tsx
@@ -1058,6 +1058,127 @@ describe('edge cases', () => {
})
})
+describe('Component.Part static access', () => {
+ test('parts are accessible as static properties', () => {
+ const Component = define('StaticParts', {
+ parts: {
+ Header: { base: 'header' },
+ Body: { base: 'main' }
+ }
+ })
+
+ expect(typeof (Component as any).Header).toBe('function')
+ expect(typeof (Component as any).Body).toBe('function')
+ })
+
+ test('static parts render with correct className and element', () => {
+ const Component = define('StaticPartRender', {
+ parts: {
+ Logo: { base: 'a', color: 'blue' },
+ Nav: { base: 'nav', display: 'flex' }
+ }
+ }) as any
+
+ const logoHtml = renderToString(Component.Logo({ href: '/', children: 'Home' }))
+ expect(logoHtml).toContain('class="StaticPartRender_Logo"')
+ expect(logoHtml).toContain(' {
+ const Card = define('StaticCard', {
+ padding: 20,
+ parts: {
+ Title: { base: 'h2', fontSize: 24 },
+ Body: { base: 'div', padding: 10 }
+ }
+ }) as any
+
+ const html = renderToString(Card({
+ children: [
+ Card.Title({ children: 'Hello' }),
+ Card.Body({ children: 'World' })
+ ]
+ }))
+ expect(html).toContain('class="StaticCard"')
+ expect(html).toContain('class="StaticCard_Title"')
+ expect(html).toContain('class="StaticCard_Body"')
+ expect(html).toContain('Hello')
+ expect(html).toContain('World')
+ })
+
+ test('variant on parent applies to static parts via descendant selector CSS', () => {
+ define('StaticVariantParts', {
+ parts: {
+ Item: { color: 'black' }
+ },
+ variants: {
+ theme: {
+ dark: {
+ backgroundColor: 'black',
+ parts: {
+ Item: { color: 'white' }
+ }
+ }
+ }
+ }
+ })
+
+ const css = getStylesCSS()
+ // Descendant selector for Component.Part usage
+ expect(css).toContain('.StaticVariantParts.theme-dark .StaticVariantParts_Item')
+ // Direct class selector still generated for render({ parts }) usage
+ expect(css).toContain('.StaticVariantParts_Item.theme-dark')
+ })
+
+ test('boolean variant on parent generates descendant selector CSS', () => {
+ define('StaticBoolVariant', {
+ parts: {
+ Icon: { base: 'span', opacity: 0.5 }
+ },
+ variants: {
+ active: {
+ parts: {
+ Icon: { opacity: 1 }
+ }
+ }
+ }
+ })
+
+ const css = getStylesCSS()
+ expect(css).toContain('.StaticBoolVariant.active .StaticBoolVariant_Icon')
+ expect(css).toContain('.StaticBoolVariant_Icon.active')
+ })
+
+ test('static parts can receive variant props directly', () => {
+ const Tabs = define('StaticTabs', {
+ parts: {
+ Tab: { base: 'button', color: 'gray' }
+ },
+ variants: {
+ active: {
+ parts: {
+ Tab: { color: 'blue' }
+ }
+ }
+ }
+ }) as any
+
+ const activeHtml = renderToString(Tabs.Tab({ active: true, children: 'Home' }))
+ expect(activeHtml).toContain('StaticTabs_Tab active')
+ expect(activeHtml).toContain('Home')
+
+ const inactiveHtml = renderToString(Tabs.Tab({ children: 'Settings' }))
+ expect(inactiveHtml).toContain('class="StaticTabs_Tab"')
+ expect(inactiveHtml).not.toContain('active')
+ })
+})
+
describe('css() - global styles', () => {
test('registers bare element selectors', () => {
css({
diff --git a/src/tests/test_helpers.ts b/src/tests/test_helpers.ts
index d615394..3b26fca 100644
--- a/src/tests/test_helpers.ts
+++ b/src/tests/test_helpers.ts
@@ -19,12 +19,12 @@ export function parseCSS(css: string): Record> {
const className = match[1]
const cssText = match[2]
- styles[className] = {}
+ styles[className] ??= {}
const propMatches = Array.from(cssText.matchAll(/\s*([^:]+):\s*([^;]+);/g))
for (const propMatch of propMatches) {
if (!propMatch[1] || !propMatch[2]) continue
- styles[className]![propMatch[1].trim()] = propMatch[2].trim()
+ styles[className]![propMatch[1].trim()] ??= propMatch[2].trim()
}
}