For the complete documentation index, see llms.txt. This page is also available as Markdown.

Part 4: Edit Mode, Theming & Polish

Perfect the edit canvas, add dark mode and an advanced escape hatch, emit FAQPage structured data, and ship

The FAQ from Part 3 works—but "works" and "ships" are different bars. In this final part you'll fix the published page's first paint without ever hiding an answer on the edit canvas, show placeholder questions while the collection is empty, pair every color for dark mode, add a scoped hover detail and the standard Advanced escape hatch, and emit FAQPage structured data so search engines can read the questions too. The part ends with the complete listing of every file in the finished component.

The Edit Canvas Comes First

A component spends most of its life on the editing canvas, and Designing the Edit-Mode Experience sets the bar: every dropzone reachable, no blank boxes, nothing canvas-hostile. Two pieces of work get us there.

Static First Paint, Always-Open Canvas

Part 3 left a flash: until Alpine boots on the published page, every answer is briefly visible before x-show collapses it. The Accordion solves this by rendering the resting state statically—its content panel carries style="display: none;", skipped in edit mode so the panel stays open on the canvas. Our version needs one more ingredient, because an answer's resting state depends on three things: it must never be hidden on the canvas, and on the published page it should start visible only when it's the first item and First Item Open is on.

Three inputs is two too many for a template @if, which takes exactly one condition. So we follow the rule the edit-mode guide repeats throughout: compute the mode boolean once in hooks.js and fold the whole decision into a single per-item flag. The item map from Part 2 gains one line:

// hooks.js — per-item resting state (complete file in the final listing below)
const edit = rw.project.mode === "edit";
const firstOpen = isOn(firstOpenOnLoad);

const items = (questions || []).map((item, index) => ({
    ...item,
    index,
    question: item.question || `Question ${index + 1}`,
    questionId: `faq-${id}-question-${index}`,
    answerId: `faq-${id}-answer-${index}`,
    showDropzone: dropzonesEnabled || isOn(item.useDropzone),
    startOpen: edit || (firstOpen && index === 0),
}));

edit comes from rw.project.mode, exactly as the core Table computes it. On the canvas every item's startOpen is true; on the published page only the first item's is—and only when the switch says so.

The template consumes the flag as a static seed next to the Alpine bindings, and gives the canvas a truthful aria-expanded while it's at it:

This is the two-mechanism split from the guide's Keeping Hidden Content Editable section: visibility on the canvas is a precomputed inline style (all answers open, every dropzone reachable), visibility at runtime belongs to Alpine, and after boot the factory's x-show takes ownership of the attribute the seed put there. First paint now matches the resting state in every combination—no flash with First Item Open on or off. (The {{classes.indicator}} span is new too; it's wired up in the theming section below.)

Placeholder Questions for an Empty Collection

Delete every question and Part 3's FAQ renders only a heading—a blank box with extra steps. The fix is the same fallback trick the core Audio Playlist uses for its missing first track (see the full example in Data Collections in hooks.js): when the collection is empty, substitute a hard-coded sample array so the component still renders its real layout.

The item map from the previous section now runs over sourceItems instead of (questions || []), so placeholders flow through the same ID, ARIA, and startOpen enrichment as real questions. Like the Gallery's empty state, the placeholder is keyed to content, not mode—an empty FAQ previews as a working accordion, not a void. The dimmed caption explaining the situation, though, is editor guidance, and "in edit mode and using placeholders" is another two-input decision—hence the precomputed showPlaceholderHint. The template appends it after the list wrapper:

Theming Polish

Three refinements finish the styling story. Here is the hook's finished classes object with every change from this part—the subsections walk through the additions:

Dark-Mode Color Pairing

Every hard-coded color now travels with a dark: partner—light surfaces pair with dark ones (bg-surface-50 / dark:bg-surface-900), dark text pairs with light (text-text-900 / dark:text-text-100), and the hover tint pairs too. This is the pairing convention described in the Dark Mode section of Component Styling; if you ever stack another variant on top of a paired value, remember that the new prefix must land after dark:—that section covers the rewrite (and the Build Tools helper that automates it).

The Accent control gets its dark half declaratively instead: a Theme Color control with darkName/darkBrightness emits a ready-made pair, which our format turns into text-brand-500 dark:text-brand-400. One edit in properties.json:

A Scoped Hover Group per Item

The + indicator should react when the pointer is anywhere over its item—a different element than the one being hovered, which is exactly what Tailwind's named groups are for. The wrapper's group/${id} from Part 1 is the wrong tool here: group-hover compiles to a descendant selector, so a group on the wrapper would fire every item's indicator whenever the pointer touched any part of the FAQ. Instead each item carries its own named group, group/item-${id}, and the indicator opts in with group-hover/item-${id}:scale-125.

Keeping the instance ID in the item's group name matters for the same reason it does on the wrapper: dropzone answers mean one FAQ can legitimately sit inside another, and an unscoped group/item on the outer item would scale the inner FAQ's indicators—they'd be descendants with a matching group name. Keyed to the ID, hover states never leak between instances. (The question's background tint needs no group at all—the hovered element and the styled element are the same, so a plain hover: variant does it.)

The Advanced Escape Hatch

Every core component ends its inspector with an Advanced group whose Classes field lets power users append their own utility classes—the convention documented in Supporting User-Defined Classes. We adopt the same control shape (a text area with the id cssClasses, matching the core components) as a fourth group in properties.json:

The hook destructures cssClasses from rw.props and appends it as the last entry of the wrapper's class array (see the finished classes object above)—.filter(Boolean) already drops it when empty.

FAQPage Structured Data

An FAQ is one of the few components whose content search engines understand natively: mark it up as FAQPage JSON-LD and the questions become machine-readable. The data is assembled in the hook, where the real strings live, with JSON.stringify doing the serialising:

Two kinds of item are deliberately excluded:

  • Placeholder questions. Structured data describing sample content would be lying to search engines, so usingPlaceholders empties the list outright.

  • Dropzone answers. The hook sees an item's data—its answer text—but a dropzone's contents are other components whose rendered output the hook can never read as a string. Structured data must mirror what's visibly on the page, so any item with showDropzone (or an empty answer) is simply not listed. The rest of the FAQ still qualifies.

The template emits the block into the page head through a portal—add this at the very top of templates/index.html:

Note what's missing compared to templates/alpine.html: no includeOnce, no id. That's deliberate. The Alpine factory is shared, identical logic—include it once. This block is per-instance data: two FAQ components on one page should emit two FAQPage blocks, each describing its own questions, which is perfectly valid structured data. An includeOnce portal would keep only the first instance's block and silently drop the rest—the exact failure mode the portals warning in the Alpine guide describes. hasJsonLd (set to textFaqs.length > 0 in the hook) keeps empty mainEntity arrays out of the head entirely.

Try It Out

Give the finished component a shakedown. Delete every question and watch the placeholders and their editor-only caption appear; add a question back and they vanish. Switch your theme (or system appearance) to dark and check the surfaces, text, and accent all flip. Hover an item and watch the indicator grow. Then publish and view source: one application/ld+json block per FAQ in the <head>, listing exactly the text answers.

[Screenshot: the empty-collection FAQ on the canvas showing two placeholder questions and the dimmed caption]

[Screenshot: the published FAQ in dark mode, one item hovered with its indicator enlarged]

The Finished Component

Every file of com.example.faq, complete. This is the final state of everything built across all four parts.

info.json

properties.json

collections/questions/info.json

collections/questions/properties.json

collections/questions/defaults.json

hooks.js

templates/index.html

templates/alpine.html

Before You Ship

Where to Go Next

The FAQ touches most of the component surface area, but each topic goes deeper than one tutorial can:

Interactive Components with Alpine.js

Full lifecycles (init()/destroy()), reduced-motion handling, and the Tabs keyboard pattern—arrow keys and a roving tabindex would be a natural upgrade for this FAQ's question buttons.

Modals, Overlays & Portals

Portals as a layout tool: moving overlay markup to bodyEnd and wiring open/close triggers across components.

Integrating JavaScript Libraries

When Alpine isn't enough: bundling third-party libraries into a pack and driving them from properties.

Last updated

Was this helpful?