ADR: Headless AI Sections — Decoupling Rendering from Behavior¶
Status¶
Draft
Date¶
2026-03-18
Context¶
Current State¶
AI Sections today are a fully rendered component: clients embed a <div data-rose-section="hero"> placeholder, and the Rose widget renders our React component into it via a portal. The component handles layout, styling, animations, and click-to-trigger behavior.
Clients can customize AI Sections in two ways:
- Backoffice UI editor — color pickers, layout dropdowns, font size controls
- Raw CSS editor — write arbitrary CSS scoped to our component's class names (
.ai-sections-button,.ai-sections-title, etc.)
Both approaches require knowledge of our internal DOM structure. The UI editor limits creative freedom to the knobs we expose. The CSS editor requires reverse-engineering our markup.
The Problem¶
For clients with dedicated design teams (using Figma, Webflow, Framer, or custom frontend stacks), both options create friction:
- Designers can't use their tools — the visual output is controlled by our React component, not by their design system
- Brand integration is limited — color overrides don't achieve pixel-perfect alignment with a client's existing page design
- Content is coupled to rendering — changing question text requires either HTML redeployment (if hardcoded) or backoffice access (if using our component)
- Adoption resistance — design teams push back on embedding third-party rendered UI that doesn't match their design language
What We Want¶
Make AI Sections adoptable by any design team on any platform, regardless of their technical stack or design tools. The ideal is: we provide content + behavior, they provide design.
Decision¶
We propose a layered strategy that introduces headless capabilities alongside the existing rendered mode. The layers are additive — nothing breaks, clients migrate at their own pace.
Layer 1: Headless Data Attributes SDK¶
Any HTML element with data-rose-question becomes a trigger that opens the Rose widget with that question. No rendering from our side — zero Shadow DOM, zero CSS injection, zero React portals.
Basic usage — static HTML¶
<!-- Client's own design — any HTML, any CSS, any framework -->
<section class="acme-hero" data-rose-section-id="homepage-hero">
<h2>How can we help?</h2>
<div class="acme-buttons">
<button class="acme-btn" data-rose-question="What does your platform do?">
What we do
</button>
<button class="acme-btn" data-rose-question="How does pricing work?">
Pricing
</button>
<button class="acme-btn" data-rose-question="Can I see a demo?">
Book a demo
</button>
</div>
</section>
Supported data attributes¶
| Attribute | Applies to | Purpose |
|---|---|---|
data-rose-question="..." |
Any element | Click triggers the widget with this question |
data-rose-search |
<input> or <textarea> |
Enter key sends freeform question to widget |
data-rose-search-submit |
<button> |
Explicit submit button for a nearby data-rose-search input |
data-rose-section-id="..." |
Container element | Groups elements for analytics + section-level visibility |
data-rose-context="..." |
Container or individual element | Hidden context prepended to the question (not shown to user) |
Search bar — freeform input¶
<form data-rose-search-form>
<input data-rose-search placeholder="Ask us anything..." class="acme-input" />
<button data-rose-search-submit type="submit">
<svg><!-- client's own icon --></svg>
</button>
</form>
The SDK prevents form submission and calls triggerQuestion(inputValue, 'headless_search', context) instead.
SPA support — dynamic content¶
// After dynamically adding new data-rose-question elements:
window.RoseSections.discoverHeadless();
Runtime behavior¶
At runtime, the headless discovery:
- Scans the DOM for
[data-rose-question]elements - Attaches click handlers that call
window.InboundXWidget.triggerQuestion(question, 'headless_button', context) - Scans for
[data-rose-search]inputs and wires Enter key / submit button - Tracks analytics with source
'headless_button'or'headless_search'
This reuses the existing triggerQuestion API and event system. No new backend work.
Layer 2: Sections Content API¶
Expose AI Section configuration as a public REST endpoint so clients can fetch questions dynamically and render them in their own design system.
Endpoint¶
GET https://api.userose.ai/v1/sections?domain=acme.com&language=en
Response:
{
"sections": [
{
"sectionId": "homepage-hero",
"title": "How can we help?",
"questions": [
"What does your platform do?",
"How does pricing work?",
"Can I see a demo?"
],
"context": "User is on the homepage",
"showSearchBar": true,
"searchBarPlaceholder": "Ask anything..."
}
]
}
Single-section fetch:
This is essentially what aiSectionsService.ts already does via the Supabase client. The endpoint wraps the same logic in a public REST API (or a Supabase Edge Function).
Integration examples¶
Vanilla JS:
<div id="hero-section" data-rose-section-id="homepage-hero"></div>
<script>
window.addEventListener('roseWidgetReady', async () => {
const res = await fetch(
'https://api.userose.ai/v1/sections?domain=acme.com§ion=homepage-hero'
);
const section = await res.json();
const container = document.getElementById('hero-section');
container.innerHTML = `
<h2 class="acme-heading">${section.title}</h2>
<div class="acme-button-group">
${section.questions.map(q => `
<button class="acme-cta-btn" data-rose-question="${q}"
data-rose-context="${section.context || ''}">
${q}
</button>
`).join('')}
</div>
`;
window.RoseSections.discoverHeadless();
});
</script>
React (client's own component):
function AcmeHeroSection() {
const [section, setSection] = useState(null);
useEffect(() => {
fetch('https://api.userose.ai/v1/sections?domain=acme.com§ion=homepage-hero')
.then(r => r.json())
.then(setSection);
}, []);
if (!section) return null;
const handleClick = (question: string) => {
window.InboundXWidget.triggerQuestion(question, 'headless_button', section.context);
};
return (
<section className="acme-hero">
<h2>{section.title}</h2>
<div className="acme-grid">
{section.questions.map(q => (
<button key={q} onClick={() => handleClick(q)} className="acme-card">
<AcmeIcon name="sparkle" />
<span>{q}</span>
</button>
))}
</div>
</section>
);
}
Webflow (custom code embed):
<div id="rose-hero" class="webflow-hero-section">
<h2 class="hero-title" id="rose-hero-title"></h2>
<div class="hero-buttons" id="rose-hero-buttons"></div>
</div>
<script>
window.addEventListener('roseWidgetReady', async () => {
const res = await fetch(
'https://api.userose.ai/v1/sections?domain=acme.com§ion=homepage-hero'
);
const section = await res.json();
document.getElementById('rose-hero-title').textContent = section.title;
const container = document.getElementById('rose-hero-buttons');
section.questions.forEach(q => {
const btn = document.createElement('button');
btn.className = 'webflow-hero-btn';
btn.setAttribute('data-rose-question', q);
btn.textContent = q;
container.appendChild(btn);
});
window.RoseSections.discoverHeadless();
});
</script>
Visibility Control — Widget-Not-Loaded Safety¶
A critical concern: if the widget fails to load (ad blocker, network failure, traffic control), headless elements must not display as broken, non-functional UI.
Section-level CSS hide¶
Embed snippet includes a CSS rule that hides entire sections until the widget confirms readiness:
<style data-rose-headless>
[data-rose-section-id] {
visibility: hidden;
}
html.rose-ready [data-rose-section-id] {
visibility: visible;
}
</style>
The SDK toggles the class reactively:
window.addEventListener('inboundx_bar_visible', () => {
document.documentElement.classList.add('rose-ready');
});
window.addEventListener('inboundx_bar_hidden', () => {
document.documentElement.classList.remove('rose-ready');
});
This mirrors the existing useWidgetReadiness hook behavior (traffic control, mobile exclusion, hidden zone) expressed as a CSS class toggle instead of React state.
visibility: hidden (not display: none) preserves layout — no CLS (Cumulative Layout Shift) when sections appear.
Auto-injected fallback¶
The widget script also injects this CSS rule as a safety net, in case the client only includes the script tag without the style block. The rule is idempotent, so duplicates don't conflict.
Preview mode for development¶
A URL parameter lets designers force-show sections while building, without a working widget:
Templating: Making It Work (Exploration)¶
Despite the WYSIWYG limitation, the <template> approach has a genuine advantage: it's safe by default. <template> elements are natively hidden by the browser, so if the widget never loads, nothing shows. No CSS snippet needed. No race conditions.
This is worth exploring further. Several approaches could make <template> viable:
Approach A: Companion preview element¶
Ship a sibling <div> that mirrors the template content for design-time preview, and is removed at runtime:
<div data-rose-auto="homepage-hero">
<!-- Design-time preview — visible in editors, removed by SDK at runtime -->
<div data-rose-preview>
<h2>How can we help?</h2>
<button class="acme-btn">Sample question</button>
<button class="acme-btn">Another question</button>
</div>
<!-- Production template — invisible in editors, cloned by SDK -->
<template data-rose-template="question">
<button class="acme-btn"><span data-rose-text></span></button>
</template>
</div>
The SDK removes [data-rose-preview] and populates from the template. The preview div uses the same CSS classes, so the design-time appearance matches production.
Drawback: the designer must maintain two copies of the markup (preview + template). If they change a class in the preview but forget the template, production diverges.
Approach B: Webflow/Framer plugin that renders templates¶
A Webflow Designer Extension or Framer plugin could:
- Detect
<template data-rose-template>elements on the canvas - Render a visual preview of what the template will produce
- Show it inline in the designer, with a "Rose preview" badge
This would give WYSIWYG inside the design tool without changing the production HTML.
Drawback: requires building and maintaining platform-specific plugins. Only works in supported editors.
Approach C: Dev-mode stylesheet¶
Ship an optional CSS file that makes templates visible during development:
<!-- Include only in dev/staging, remove in production -->
<link rel="stylesheet" href="https://cdn.userose.ai/dev/rose-template-preview.css" />
/* rose-template-preview.css */
template[data-rose-template] {
display: block !important;
border: 2px dashed #a78bfa;
padding: 8px;
position: relative;
}
template[data-rose-template]::before {
content: "Rose template: " attr(data-rose-template);
position: absolute;
top: -20px;
left: 0;
font-size: 11px;
color: #a78bfa;
font-family: monospace;
}
Drawback: <template> content is not part of the DOM tree — even with display: block, browsers don't render children of <template>. This approach fundamentally cannot work with native <template> elements. Would need to use a different element (e.g., <div data-rose-template hidden>) which loses the native safety benefit.
Approach D: <div hidden> instead of <template>¶
Replace <template> with a <div> that uses the HTML hidden attribute:
<div data-rose-auto="homepage-hero">
<div data-rose-template="question" hidden>
<button class="acme-btn"><span data-rose-text></span></button>
</div>
</div>
hidden is respected by all browsers (acts as display: none), but unlike <template>, the content IS in the DOM and can be made visible with a single CSS override during development:
/* Dev-only: show Rose templates */
[data-rose-template][hidden] {
display: block !important;
outline: 2px dashed #a78bfa;
}
The SDK would cloneNode(true) from the hidden div, remove the hidden attribute from clones, set content, and append.
Trade-off vs <template>: hidden elements are in the DOM (scripts inside them execute, images load). <template> is truly inert. For our use case this doesn't matter — templates contain simple button/text markup, not scripts or images.
This is likely the best middle ground: safe by default (hidden), previewable with one CSS rule, simpler than maintaining a preview div, no plugin required.
Alternatives Considered¶
A. Figma Codegen Plugin¶
A Figma Dev Mode plugin that inspects a designed component and generates the correct embed code with data-rose-* attributes.
Why not chosen as primary: Figma is a design tool, not a website builder. The plugin outputs code, but a developer still needs to deploy it. Doesn't solve the self-serve problem. Could be a useful complementary tool alongside the headless approach.
B. Webflow App / Code Component¶
A Webflow marketplace app with a code component that designers drag onto pages and configure via the properties panel.
Why not chosen as primary: Only helps Webflow customers. Our headless approach works on Webflow (via custom code embed) without requiring a dedicated app. A Webflow app could be built later as a convenience wrapper around the headless SDK.
C. Framer Code Component¶
Same concept as Webflow — a React code component with addPropertyControls exposed in Framer's visual editor.
Why not chosen as primary: Framer-only. Smaller market than Webflow. The headless SDK already works in Framer via custom code injection.
D. Builder.io / Plasmic Integration¶
Register our AI Section as a custom component in a headless CMS visual editor. Non-technical users drag-drop and configure.
Why not chosen as primary: Requires the client to already use Builder.io or Plasmic. Adds a dependency. Could be explored later as an integration on top of the content API.
E. Template Marketplace (Pre-designed Themes)¶
Ship 5-10 professionally designed AI Section templates (floating cards, command bar, sidebar CTA, etc.) selectable in the backoffice.
Why not chosen as primary: Still our rendered HTML — doesn't solve the design freedom problem for clients with dedicated design teams. Good for SMB clients without designers. Could be built as a separate initiative.
F. CSS-Only Design Tokens¶
Expose comprehensive CSS custom properties (--rose-font-family, --rose-radius, --rose-btn-bg, etc.) that clients set to match their brand.
Why not chosen as primary: Still limited to our component's structure. Can't change layout fundamentally. Good as an incremental improvement to the existing rendered mode.
G. Web Component with Slots¶
A <rose-section> web component that handles behavior but accepts client markup via <slot> elements.
Why not chosen as primary: Web Components + slots can be quirky across frameworks (Vue, Angular, React all handle slots differently). The data attributes approach is simpler and more universal.
Consequences¶
Positive¶
- Universal compatibility — works on any site, any framework, any design tool output
- Design freedom — clients control 100% of the visual output
- Gradual adoption — additive to the existing rendered mode, nothing breaks
- Lower support burden — no more debugging custom CSS conflicts with our internal DOM structure
- Content/behavior separation — marketing manages questions in backoffice, designers manage presentation in their tools
- Smaller payload for headless mode — no React portal rendering, no CSS injection, no Shadow DOM for sections
Negative¶
- No guaranteed UX quality — clients own the rendering, so poorly designed sections are possible
- No live preview in backoffice — the backoffice can preview content but not the client's custom design
- More integration surface — data attributes + API + visibility CSS is more to document than "paste this div"
- Content API adds a public endpoint — needs rate limiting, CORS configuration, and caching strategy
Neutral¶
- The existing rendered mode (
data-rose-section) remains fully supported. No migration required. - Analytics tracking works identically — only the
sourcefield changes ('headless_button'vs'ai_section_button') - The backoffice AI Sections editor continues to manage content for both modes
- Platform-specific integrations (Webflow app, Framer component, Figma plugin) can be built later as convenience layers on top of the headless SDK, without architectural changes
Implementation Scope¶
What already exists and can be reused¶
| Component | Status | Notes |
|---|---|---|
window.InboundXWidget.triggerQuestion() |
Exists | Core interaction mechanism, no changes needed |
Custom events (inboundx_bar_visible, etc.) |
Exists | Visibility signaling, no changes needed |
Supabase config.ai_sections table |
Exists | Data store for all section content |
aiSectionsService.ts |
Exists | Data fetching + transformation logic |
| Backoffice AI Sections editor | Exists | Content management UI, no changes needed |
What needs to be built¶
| Component | Effort | Description |
|---|---|---|
| Headless discovery function | Small | Scan DOM for data-rose-question, attach click handlers |
| Search handler | Small | Wire Enter/submit on data-rose-search inputs |
rose-ready class toggle |
Small | Add/remove CSS class on widget visibility events |
| REST API endpoint | Small | Wrap existing aiSectionsService logic in a public endpoint |
| Embed snippet generator | Small | Backoffice UI to generate the headless embed code |
| Documentation | Medium | Integration guides for vanilla JS, React, Webflow, Framer |