glokki
View .md

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

  1. At registration time — createBlockRegistry() evaluates the slot function with defaultProps to produce the initial slot definitions for the CMS handshake.

  2. At edit time — when the editor changes a prop that affects slot count (e.g., changing columns from 3 to 4), the SDK re-evaluates the slot function with the new props and the CMS updates its slot zones.

  3. 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

  • slotContents is 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 — slotContents is 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:

  1. Desktop — the block renders with its base defaultProps (or the editor's current prop values)
  2. Tablet — the tablet overrides are shallow-merged onto the base props
  3. Mobile — the mobile overrides 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

ApproachUse When
viewportDefaultsProp values should change (e.g., hide a section, reduce column count)
CSS media queriesVisual 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:

TokenPurpose
bg-background / text-foregroundPage background / primary text
bg-card / text-card-foregroundCard surfaces
bg-muted / text-muted-foregroundSubdued backgrounds / secondary text
bg-primary / text-primary-foregroundPrimary brand color / text on primary
bg-accent / text-accent-foregroundAccent color for highlights
borderDefault border color
bg-destructiveError / 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 useEffect for 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 uses useState
  • Dynamic slots derived from the tabs array prop
  • Conditional rendering of icons via showIcons toggle
  • 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", and aria-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
  • allowedTypes is 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