Custom Component Development
Advanced patterns for building custom block types beyond the standard set.
This guide covers advanced block development techniques: container blocks with slots, viewport-responsive props, conditional rendering, and styling integration. For the basics of creating blocks, see the Block Development Guide first.
1. When to Build Custom Blocks
Standard blocks (Hero, CTA, Rich Text, Image) cover common landing page needs. Build a custom block when you need:
- Layout composition — blocks that contain other blocks (e.g., multi-column layouts, tabs, accordions)
- Complex interactivity — carousels, modals, expandable sections with client-side state
- Domain-specific content — pricing tables, comparison charts, team grids with custom data shapes
- Responsive behavior — blocks whose layout changes substantially across breakpoints
- Brand-specific design — components that consume your design system tokens
All custom blocks use the same defineBlock() + createBlockRegistry() pipeline. The advanced features covered here — slots, viewport defaults, conditional rendering — are built into the SDK and require no CMS-side changes.
2. Container Blocks with Slots
Slots let a block define named drop zones where editors can place child blocks. This is the foundation for layout blocks like columns, sections, tabs, and sidebars.
Defining Static Slots
Pass a slots array to defineBlock(). Each slot is a SlotDefinition with a name, a label, and an optional allowedTypes filter:
import { defineBlock } from "@glokki-cms/sdk"
import { z } from "zod"
const sidebarLayoutSchema = z.object({
sidebarWidth: z
.enum(["narrow", "medium", "wide"])
.default("medium")
.describe("Sidebar Width"),
sidebarPosition: z
.enum(["left", "right"])
.default("left")
.describe("Sidebar Position"),
})
export const SidebarLayoutBlock = defineBlock({
type: "sidebar-layout",
label: "Sidebar Layout",
category: "layout",
icon: "PanelLeft",
defaultProps: {
sidebarWidth: "medium",
sidebarPosition: "left",
},
propsSchema: sidebarLayoutSchema,
component: SidebarLayoutComponent,
slots: [
{ name: "sidebar", label: "Sidebar" },
{
name: "content",
label: "Content Area",
allowedTypes: ["rich-text", "image", "cta", "features-grid"],
},
],
})
Slot Type Restrictions
Use allowedTypes to constrain which block types can be dropped into a slot:
slots: [
{
name: "header",
label: "Header",
allowedTypes: ["hero", "banner"], // Only Hero and Banner blocks
},
{
name: "body",
label: "Body",
// No allowedTypes — any block type can be dropped here
},
]
When allowedTypes is omitted, the slot accepts all registered block types. The CMS enforces these restrictions in the Component Library — disallowed types are greyed out when the slot is selected.
3. Dynamic Slots
When the number or configuration of slots depends on a prop value, pass a function instead of an array:
const columnsSchema = z.object({
columns: z.number().min(1).max(6).default(3).describe("Number of Columns"),
gap: z
.enum(["none", "small", "medium", "large"])
.default("medium")
.describe("Column Gap"),
})
export const ColumnsBlock = defineBlock({
type: "columns",
label: "Columns",
category: "layout",
icon: "Columns3",
defaultProps: { columns: 3, gap: "medium" },
propsSchema: columnsSchema,
component: ColumnsComponent,
slots: (props) =>
Array.from({ length: props.columns }, (_, i) => ({
name: `column-${i}`,
label: `Column ${i + 1}`,
})),
})
How Dynamic Slots Work
-
At registration time —
createBlockRegistry()evaluates the slot function withdefaultPropsto produce the initial slot definitions for the CMS handshake. -
At edit time — when the editor changes a prop that affects slot count (e.g., changing
columnsfrom 3 to 4), the SDK re-evaluates the slot function with the new props and the CMS updates its slot zones. -
Content preservation — when reducing the number of slots (e.g., 4 columns to 2), blocks in removed slots are preserved in the component tree data but not rendered. Increasing the count again restores them.
Dynamic Slots with Type Restrictions
slots: (props) =>
Array.from({ length: props.columns }, (_, i) => ({
name: `column-${i}`,
label: `Column ${i + 1}`,
allowedTypes: props.restrictedMode
? ["rich-text", "image"]
: undefined,
})),
4. Rendering Slot Contents
When a block has child blocks in its slots, the rendering system passes a slotContents prop to your component. This is a record mapping slot names to rendered React nodes:
type SlotContents = Record<string, React.ReactNode>
type SidebarLayoutProps = z.infer<typeof sidebarLayoutSchema> & {
slotContents?: SlotContents
}
Basic Slot Rendering
function SidebarLayoutComponent({
sidebarWidth = "medium",
sidebarPosition = "left",
slotContents,
}: SidebarLayoutProps) {
const widthClasses = {
narrow: "w-48 lg:w-56",
medium: "w-64 lg:w-72",
wide: "w-80 lg:w-96",
} as const
const sidebar = (
<aside className={`shrink-0 ${widthClasses[sidebarWidth]}`}>
{slotContents?.sidebar}
</aside>
)
const content = (
<main className="min-w-0 flex-1">{slotContents?.content}</main>
)
return (
<div className="flex gap-6 px-6 py-8">
{sidebarPosition === "left" ? (
<>
{sidebar}
{content}
</>
) : (
<>
{content}
{sidebar}
</>
)}
</div>
)
}
Slot Rendering Rules
slotContentsis injected automatically — you do not need to declare it in the Zod schema. The rendering system adds it when the block has children.- Always check for undefined —
slotContentsis undefined when the block has no children, and a specific slot key is undefined when that slot is empty. - Slots render as React nodes — each slot value is a
ReactNode(a single element or fragment). You place it directly in your JSX. - No direct child manipulation — you cannot inspect or modify the child blocks. The slot is an opaque rendered output.
Empty Slot Placeholder
Provide a visual placeholder for empty slots so editors know where to drop blocks:
function SlotPlaceholder({ label }: { label: string }) {
return (
<div className="flex min-h-[80px] items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 p-4 text-sm text-muted-foreground">
Drop blocks into {label}
</div>
)
}
function SidebarLayoutComponent({
sidebarWidth = "medium",
sidebarPosition = "left",
slotContents,
}: SidebarLayoutProps) {
const widthClasses = {
narrow: "w-48 lg:w-56",
medium: "w-64 lg:w-72",
wide: "w-80 lg:w-96",
} as const
return (
<div className="flex gap-6 px-6 py-8">
<aside className={`shrink-0 ${widthClasses[sidebarWidth]}`}>
{slotContents?.sidebar ?? <SlotPlaceholder label="Sidebar" />}
</aside>
<main className="min-w-0 flex-1">
{slotContents?.content ?? <SlotPlaceholder label="Content" />}
</main>
</div>
)
}
5. Viewport-Responsive Props
Use viewportDefaults to declare how a block's props should differ across breakpoints. These defaults are serialised into the block definition and sent to the CMS during the handshake. The CMS can use them to suggest different prop values when editors switch viewport preview modes (desktop, tablet, mobile).
Declaring Viewport Defaults
export const PricingTableBlock = defineBlock({
type: "pricing-table",
label: "Pricing Table",
category: "content",
icon: "CreditCard",
defaultProps: {
columns: 3,
showFeatureComparison: true,
layout: "horizontal",
cardSize: "large",
},
propsSchema: pricingTableSchema,
component: PricingTableComponent,
viewportDefaults: {
tablet: {
columns: 2,
cardSize: "medium",
},
mobile: {
columns: 1,
showFeatureComparison: false,
layout: "stacked",
cardSize: "small",
},
},
})
How Viewport Overrides Work
Viewport defaults declare the intended prop values per breakpoint. They are shallow-merged onto the base props:
- Desktop — the block renders with its base
defaultProps(or the editor's current prop values) - Tablet — the
tabletoverrides are shallow-merged onto the base props - Mobile — the
mobileoverrides are shallow-merged onto the base props
Overrides are shallow — they replace top-level prop values, not nested object properties. For array props (like features), the entire array is replaced if overridden.
Note: Viewport defaults are metadata — they tell the CMS what prop values are appropriate per breakpoint. Your component should also use CSS responsive classes (Tailwind breakpoints) for visual adaptations that don't require prop changes.
Responsive Component Patterns
Design your component to respond to the props it receives, not to CSS media queries. This ensures the editor preview matches the actual behavior:
type PricingTableProps = z.infer<typeof pricingTableSchema>
const COLUMN_CLASSES: Record<number, string> = {
1: "grid-cols-1",
2: "grid-cols-1 sm:grid-cols-2",
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
}
function PricingTableComponent({
columns = 3,
showFeatureComparison = true,
layout = "horizontal",
cardSize = "large",
plans = [],
}: PricingTableProps) {
const sizeClasses = {
small: "p-4 text-sm",
medium: "p-6 text-base",
large: "p-8 text-lg",
} as const
return (
<section className="px-6 py-12">
<div className={`grid gap-6 ${COLUMN_CLASSES[columns] ?? "grid-cols-1"}`}>
{plans.map((plan) => (
<PricingCard
key={plan.name}
plan={plan}
className={sizeClasses[cardSize]}
showFeatures={showFeatureComparison}
/>
))}
</div>
</section>
)
}
Viewport Defaults vs CSS Media Queries
| Approach | Use When |
|---|---|
viewportDefaults | Prop values should change (e.g., hide a section, reduce column count) |
| CSS media queries | Visual styling changes only (e.g., font size, padding, flex direction) |
Use both together: viewportDefaults for structural changes that the editor should preview, and CSS/Tailwind responsive classes for fine-grained visual adjustments.
6. Conditional Rendering
Conditional rendering lets editors toggle parts of a block on or off via props. This is more flexible than creating separate block types for each variation.
Boolean Toggle Pattern
Add a boolean prop to control visibility of a section:
const heroSchema = z.object({
heading: z.string().min(1).max(120).default("Welcome").describe("Heading"),
showSubheading: z.boolean().default(true).describe("Show Subheading"),
subheading: z.string().max(240).optional().describe("Subheading"),
showCta: z.boolean().default(true).describe("Show CTA Button"),
ctaLabel: z.string().max(60).optional().describe("CTA Label"),
ctaUrl: z.string().url().optional().describe("CTA URL"),
showBadge: z.boolean().default(false).describe("Show Badge"),
badgeText: z.string().max(40).optional().describe("Badge Text"),
})
function HeroComponent({
heading = "Welcome",
showSubheading = true,
subheading,
showCta = true,
ctaLabel,
ctaUrl,
showBadge = false,
badgeText,
}: HeroProps) {
return (
<section className="px-6 py-16 text-center">
{showBadge && badgeText && (
<span className="mb-4 inline-block rounded-full bg-accent/10 px-3 py-1 text-xs font-medium text-accent">
{badgeText}
</span>
)}
<h1 className="text-4xl font-bold">{heading}</h1>
{showSubheading && subheading && (
<p className="mt-4 text-lg text-muted-foreground">{subheading}</p>
)}
{showCta && ctaLabel && (
<a
href={ctaUrl ?? "#"}
className="mt-6 inline-block rounded-md bg-primary px-6 py-2.5 text-primary-foreground"
>
{ctaLabel}
</a>
)}
</section>
)
}
Enum Variant Pattern
Use an enum to switch between distinct layout variants:
const cardSchema = z.object({
variant: z
.enum(["simple", "detailed", "compact"])
.default("simple")
.describe("Card Variant"),
title: z.string().min(1).max(100).default("Card Title").describe("Title"),
description: z.string().max(300).optional().describe("Description"),
image: z
.string()
.url()
.optional()
.meta({ fieldType: "image" })
.describe("Image"),
showMeta: z.boolean().default(false).describe("Show Metadata"),
date: z.string().optional().describe("Date"),
author: z.string().optional().describe("Author"),
})
function CardComponent({
variant = "simple",
title = "Card Title",
description,
image,
showMeta = false,
date,
author,
}: CardProps) {
if (variant === "compact") {
return (
<div className="flex items-center gap-3 rounded-lg border p-3">
{image && (
<img src={image} alt="" className="h-12 w-12 rounded object-cover" />
)}
<div>
<h3 className="font-medium">{title}</h3>
{showMeta && date && (
<span className="text-xs text-muted-foreground">{date}</span>
)}
</div>
</div>
)
}
if (variant === "detailed") {
return (
<article className="overflow-hidden rounded-xl border shadow-sm">
{image && (
<img
src={image}
alt=""
className="aspect-video w-full object-cover"
/>
)}
<div className="p-6">
<h3 className="text-xl font-bold">{title}</h3>
{description && (
<p className="mt-2 text-muted-foreground">{description}</p>
)}
{showMeta && (
<div className="mt-4 flex gap-2 text-sm text-muted-foreground">
{author && <span>{author}</span>}
{date && <span>{date}</span>}
</div>
)}
</div>
</article>
)
}
return (
<div className="rounded-lg border p-4">
<h3 className="font-semibold">{title}</h3>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
)
}
Conditional Rendering Guidelines
- Always provide a fallback — if a toggle hides all content, show at least the block wrapper so the editor can still select it.
- Guard against undefined — combine the toggle check with a value check:
{showCta && ctaLabel && ...}prevents rendering an empty button. - Keep variants manageable — if you have more than 4-5 variants, consider splitting into separate block types instead.
- Document visible props — use
.meta({ helpText: "..." })to explain when each conditional field appears:badgeText: z .string() .max(40) .optional() .meta({ helpText: "Only visible when 'Show Badge' is enabled" }) .describe("Badge Text"),
7. Styling Integration
Blocks render inside the tenant app's CSS context. The CMS iframe inherits the tenant's styles, so blocks should use the tenant's styling system.
Tailwind CSS
Tailwind is the recommended approach. Use utility classes directly in your components:
function FeatureSectionComponent({
heading,
features,
bgColor = "default",
}: FeatureSectionProps) {
const bgClasses = {
default: "bg-background",
muted: "bg-muted",
accent: "bg-accent/5",
dark: "bg-foreground text-background",
} as const
return (
<section
className={`px-6 py-16 ${bgClasses[bgColor] ?? bgClasses.default}`}
>
{heading && (
<h2 className="mb-10 text-center text-2xl font-bold">{heading}</h2>
)}
<div className="mx-auto grid max-w-5xl gap-8 sm:grid-cols-2 lg:grid-cols-3">
{features.map((feature) => (
<div
key={feature.title}
className="rounded-lg border bg-card p-6 shadow-sm"
>
<h3 className="text-lg font-semibold">{feature.title}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{feature.description}
</p>
</div>
))}
</div>
</section>
)
}
Using shadcn/ui Semantic Colors
The recommended color approach is to use shadcn/ui semantic color tokens (bg-background, text-foreground, bg-card, text-muted-foreground, etc.). These adapt to the tenant's theme and support dark mode automatically:
| Token | Purpose |
|---|---|
bg-background / text-foreground | Page background / primary text |
bg-card / text-card-foreground | Card surfaces |
bg-muted / text-muted-foreground | Subdued backgrounds / secondary text |
bg-primary / text-primary-foreground | Primary brand color / text on primary |
bg-accent / text-accent-foreground | Accent color for highlights |
border | Default border color |
bg-destructive | Error / danger states |
Inline Styles for Dynamic Values
Use inline styles only for values that come from props and cannot be expressed as utility classes:
function DividerComponent({
color = "#e5e7eb",
thickness = 1,
style = "solid",
}: DividerProps) {
return (
<hr
className="my-8 border-0"
style={{
borderTopWidth: `${thickness}px`,
borderTopStyle: style,
borderTopColor: color,
}}
/>
)
}
CSS Variables for Prop-Driven Theming
For blocks with multiple color props, use CSS custom properties to avoid scattering inline styles:
function BannerComponent({
bgColor = "#1e40af",
textColor = "#ffffff",
accentColor = "#fbbf24",
heading,
cta,
}: BannerProps) {
return (
<section
className="px-6 py-12"
style={
{
"--banner-bg": bgColor,
"--banner-text": textColor,
"--banner-accent": accentColor,
} as React.CSSProperties
}
>
<div
className="mx-auto max-w-3xl text-center"
style={{
color: "var(--banner-text)",
backgroundColor: "var(--banner-bg)",
}}
>
<h2 className="text-2xl font-bold">{heading}</h2>
{cta && (
<a
href={cta.url}
className="mt-4 inline-block rounded-md px-5 py-2 font-medium"
style={{
backgroundColor: "var(--banner-accent)",
color: "var(--banner-bg)",
}}
>
{cta.label}
</a>
)}
</div>
</section>
)
}
Color Picker Fields
Expose color props using the color field type:
bgColor: z
.string()
.default("#1e40af")
.meta({ fieldType: "color" })
.describe("Background Color"),
The CMS renders a color picker in the PropsPanel for these fields.
8. Composition Patterns
Wrapper Block Pattern
Create a generic wrapper block that adds spacing, background, or constraints around its children:
const sectionSchema = z.object({
maxWidth: z
.enum(["narrow", "default", "wide", "full"])
.default("default")
.describe("Max Width"),
padding: z
.enum(["none", "small", "medium", "large"])
.default("medium")
.describe("Padding"),
background: z
.enum(["transparent", "muted", "accent", "dark"])
.default("transparent")
.describe("Background"),
})
export const SectionBlock = defineBlock({
type: "section",
label: "Section",
category: "layout",
icon: "Square",
defaultProps: {
maxWidth: "default",
padding: "medium",
background: "transparent",
},
propsSchema: sectionSchema,
component: SectionComponent,
slots: [{ name: "content", label: "Section Content" }],
})
function SectionComponent({
maxWidth = "default",
padding = "medium",
background = "transparent",
slotContents,
}: SectionProps & { slotContents?: Record<string, React.ReactNode> }) {
const maxWidthClasses = {
narrow: "max-w-2xl",
default: "max-w-5xl",
wide: "max-w-7xl",
full: "max-w-none",
} as const
const paddingClasses = {
none: "",
small: "px-4 py-6",
medium: "px-6 py-12",
large: "px-8 py-20",
} as const
const bgClasses = {
transparent: "",
muted: "bg-muted",
accent: "bg-accent/5",
dark: "bg-foreground text-background",
} as const
return (
<section className={`${paddingClasses[padding]} ${bgClasses[background]}`}>
<div className={`mx-auto ${maxWidthClasses[maxWidth]}`}>
{slotContents?.content}
</div>
</section>
)
}
Nested Slots Pattern
Blocks with slots can be nested. A Columns block can contain Section blocks, which can contain Rich Text blocks. The nesting depth is unlimited — the rendering system handles it recursively.
Page
└── Columns Block (3 columns)
├── column-0: Section Block
│ └── content: Rich Text Block
├── column-1: Image Block
└── column-2: Section Block
└── content: CTA Block
Restricting Nesting
Use allowedTypes to prevent infinite or nonsensical nesting:
// A Section block that cannot contain other Section blocks
slots: [
{
name: "content",
label: "Content",
allowedTypes: ["rich-text", "image", "cta", "features-grid", "stats"],
// Omit "section" to prevent Section-in-Section nesting
},
],
9. Performance Considerations
Keep Components Pure
Blocks are re-rendered frequently during editing (on every prop change, selection change, and viewport switch). Avoid expensive computations in the render path:
// Avoid: recomputing on every render
function BadExample({ items }: Props) {
const sorted = items.sort((a, b) => a.order - b.order) // Mutates array!
return <div>{sorted.map(...)}</div>
}
// Prefer: stable computation
function GoodExample({ items }: Props) {
const sorted = [...items].sort((a, b) => a.order - b.order)
return <div>{sorted.map(...)}</div>
}
Avoid Side Effects
Block components should not:
- Fetch data (data comes from the Delivery API via the component tree)
- Write to localStorage or sessionStorage
- Subscribe to global events
- Use
useEffectfor anything beyond basic DOM measurement
Image Optimization
For image props, use the tenant app's image optimization (e.g., Next.js <Image> or responsive srcset):
function ImageBlockComponent({ src, alt, width, height }: ImageBlockProps) {
return (
<img
src={src}
alt={alt ?? ""}
width={width}
height={height}
loading="lazy"
decoding="async"
className="h-auto w-full"
/>
)
}
10. Full Example: Tabs Block
A complete example combining dynamic slots, conditional rendering, and viewport-responsive props:
"use client"
import { defineBlock } from "@glokki-cms/sdk"
import { useState } from "react"
import { z } from "zod"
const tabItemSchema = z.object({
label: z.string().min(1).max(60).describe("Tab Label"),
icon: z
.enum(["none", "star", "heart", "info", "settings"])
.default("none")
.describe("Icon"),
})
const tabsSchema = z.object({
tabs: z
.array(tabItemSchema)
.default([
{ label: "Overview", icon: "none" },
{ label: "Features", icon: "star" },
{ label: "Pricing", icon: "none" },
])
.describe("Tabs"),
alignment: z
.enum(["left", "center", "stretch"])
.default("left")
.describe("Tab Alignment"),
showIcons: z.boolean().default(false).describe("Show Tab Icons"),
})
type TabsProps = z.infer<typeof tabsSchema> & {
slotContents?: Record<string, React.ReactNode>
}
const ICON_MAP: Record<string, string> = {
star: "\u2605",
heart: "\u2764\uFE0F",
info: "\u2139\uFE0F",
settings: "\u2699\uFE0F",
}
function TabsComponent({
tabs = [],
alignment = "left",
showIcons = false,
slotContents,
}: TabsProps) {
const [activeIndex, setActiveIndex] = useState(0)
const alignmentClasses = {
left: "justify-start",
center: "justify-center",
stretch: "",
} as const
return (
<div className="px-6 py-8">
<div
className={`flex border-b ${alignmentClasses[alignment]}`}
role="tablist"
>
{tabs.map((tab, i) => (
<button
key={`${tab.label}-${i}`}
role="tab"
aria-selected={i === activeIndex}
onClick={() => setActiveIndex(i)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
alignment === "stretch" ? "flex-1" : ""
} ${
i === activeIndex
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{showIcons && tab.icon !== "none" && (
<span className="mr-1.5">{ICON_MAP[tab.icon]}</span>
)}
{tab.label}
</button>
))}
</div>
<div className="pt-6" role="tabpanel">
{slotContents?.[`tab-${activeIndex}`]}
</div>
</div>
)
}
export const TabsBlock = defineBlock({
type: "tabs",
label: "Tabs",
category: "layout",
icon: "Layers",
defaultProps: {
tabs: [
{ label: "Overview", icon: "none" as const },
{ label: "Features", icon: "star" as const },
{ label: "Pricing", icon: "none" as const },
],
alignment: "left",
showIcons: false,
},
propsSchema: tabsSchema,
component: TabsComponent,
slots: (props) =>
props.tabs.map((tab: { label: string }, i: number) => ({
name: `tab-${i}`,
label: tab.label,
})),
viewportDefaults: {
mobile: {
alignment: "stretch",
},
},
})
This example demonstrates:
"use client"directive — required because the component usesuseState- Dynamic slots derived from the
tabsarray prop - Conditional rendering of icons via
showIconstoggle - Viewport-responsive defaults that stretch tabs on mobile
- Client-side state (
useState) for active tab tracking - Slot contents rendered in the active tab panel
Note on index-based slot names: This example uses
tab-${i}for slot names, which means slot identity is positional. If editors reorder tabs, child blocks stay attached to slot indexes, not tab labels. For blocks where tab reordering is common, consider adding a stable ID field to the tab item schema and using it as the slot name instead.
- Accessibility with
role="tablist",role="tab", andaria-selected
11. Checklist for Advanced Blocks
Beyond the basic checklist, verify:
- Slots render correctly — child blocks appear in the right slot areas
- Empty slots have placeholders — editors can see where to drop blocks
- Dynamic slots update — changing the controlling prop creates/removes slot zones
-
allowedTypesis set — prevent nonsensical block nesting - Viewport defaults are sensible — mobile view simplifies rather than just shrinks
- Conditional sections have toggle props — editors can hide/show optional elements
- Conditional guards check both toggle and value — e.g.,
showCta && ctaLabel - No hardcoded colors — use semantic tokens or color picker fields
- Component is pure — no data fetching, no side effects, no DOM manipulation
- Component handles missing
slotContents— renders gracefully without children
Next Steps
- Block Development Guide — fundamentals of block creation
- Integration Guide — project setup and visual editor integration
- Troubleshooting Guide — common issues and solutions
- API Reference — full SDK export documentation