| name | create-new-block |
| description | Scaffold and implement a new EDS block for Spectrum Hub. Use when adding a new block — covers file structure, the init(el) function contract, template injection, CSS conventions, and when to use templates vs per-page authoring vs fragments. |
Create a New Block
Use this skill when creating a new block from scratch or wiring an existing scaffolded block into the page lifecycle.
Block file structure
Every block lives in its own folder under blocks/. The folder name must exactly match the class name that loadBlock uses to resolve it.
blocks/
<block-name>/
<block-name>.js
<block-name>.css
How loadBlock resolves a block
export async function loadBlock(block) {
const { components } = getConfig();
const [name] = block.classList;
block.dataset.blockName = name;
const style = !components.some((cmp) => name === cmp);
await loadExperience(block, 'blocks', name, style);
return block;
}
Key rules:
- The first class on the element is the block name — no secondary
block class is needed.
loadExperience loads blocks/<name>/<name>.js and, if style is true, blocks/<name>/<name>.css.
- CSS loads automatically for all blocks unless the block name is listed in the
components array in scripts.js (currently ['fragment', 'schedule']). Add a block there only if it manages its own CSS loading.
- No registration required — adding a folder under
blocks/ with matching JS and CSS files is sufficient.
The init(el) contract
export default async function init(el) {
}
el is whatever element was passed to loadBlock — could be a <div>, <nav>, <aside>, etc.
- The caller (template or page) may have already set
className, aria-label, and other attributes on el. Do not overwrite them.
- Append content into
el directly. Use el.replaceChildren() only when the block is authored directly in a page document with no template pre-creating it.
init is async — await any fetches before appending content.
How templates inject blocks
Templates live in templates/<name>/<name>.js and run when a page's template metadata matches. They pre-create the block element and call loadBlock, which resolves the matching block JS by class name.
import { loadBlock } from '../../scripts/ak.js';
export default async function init() {
const main = document.querySelector('main');
const wrapper = document.createElement('div');
wrapper.className = 'template-wrapper';
const myBlock = document.createElement('div');
myBlock.className = 'my-block';
myBlock.setAttribute('aria-label', '...');
await loadBlock(myBlock);
main.replaceWith(wrapper);
wrapper.append(myBlock, main);
}
For multiple independent blocks, load them in parallel:
await Promise.all([loadBlock(sitenav), loadBlock(inPageNav)]);
When to use templates vs per-page authoring vs fragments
| Approach | Use when |
|---|
| Template | Block appears on every page of a given type (e.g., sitenav on all landing and detail pages). Registered in templates/<name>/. |
| Per-page authoring | Block is content-specific — authors add it to individual pages in the document editor via a table with the block name as the header. |
| Fragment | Content is shared across pages but not global (not header/footer). Author a document at /fragments/<path> and reference it with a fragment block table on each page. |
Header and footer are special — they are baked into the HTML shell by the delivery pipeline, not authored per-page. ak.js finds those empty elements and calls loadBlock on them automatically.
CSS file
loadBlock automatically loads blocks/<name>/<name>.css alongside the JS — no import needed. The CSS file should scope all styles to the block root class and use BEM for any child elements.
For the full CSS authoring reference — design tokens, light/dark mode, nesting conventions, media query syntax, reduced motion, and global utilities — see .ai/skills/stylesheet-conventions/SKILL.md.
Querying data from the index
Dynamic blocks (sitenav, in-page-nav) fetch data at runtime rather than reading authored content.
const resp = await fetch('/query-index.json');
if (!resp.ok) return;
const { data } = await resp.json();
data is an array of page objects: { path, title, description, ... }.
Filter by the current top-level URL section:
const [, topSection] = window.location.pathname.split('/');
const sectionPages = data.filter(({ path }) => path.startsWith(`/${topSection}/`));
For reading headings from the current page (TOC/in-page-nav pattern):
const headings = [...document.querySelectorAll('main h2, main h3')]
.filter((h) => !el.contains(h));
Block variants and modifiers
Extra classes on a block element are variant/modifier flags. loadBlock always uses only the first class as the block name — additional classes do not affect which JS or CSS file loads. They are purely CSS targets.
<div class="hero centered dark">...</div>
.hero {
&.centered { text-align: center; }
&.dark { background-color: var(--s2-gray-1000); color: var(--s2-gray-25); }
}
If a variant needs meaningfully different JS behavior, check for the modifier class inside init:
export default async function init(el) {
const isDark = el.classList.contains('dark');
}
Content wrappers inside el
When a block is authored directly in a page document, ak.js runs groupChildren on each section before loading blocks. It groups consecutive children into wrapper divs based on element type:
.default-content — wraps runs of non-div elements (paragraphs, headings, images, inline text)
.block-content — wraps runs of div elements (nested block containers)
loadBlock preserves these wrappers — they are present in the DOM when init(el) runs. Individual block JS may choose to strip them after reading the content, but that is the block's own decision.
Account for the wrappers when querying inside el:
export default async function init(el) {
const defaultContent = el.querySelector('.default-content');
const blockContent = el.querySelector('.block-content');
const links = el.querySelectorAll('a');
}
Blocks injected by templates (e.g. sitenav, in-page-nav) are created programmatically with no authored content — el has no children when init starts, so no wrappers are present.
Block authoring conventions
Reduce div soup
EDS decoration leaves unnecessary container elements in the DOM. Use replaceWith to swap wrapper divs for semantic elements rather than appending inside them.
const section = el.querySelector('.default-content');
const article = document.createElement('article');
article.innerHTML = section.innerHTML;
section.replaceWith(article);
Where it is necessary to retain classes from a parent or grandparent (e.g. EDS-injected classes), copy them onto the replacement element before calling replaceWith.
Prefer object syntax for DOM data
When reading block content from table rows, assign it to a named object first. This keeps optional chaining isolated to one place and makes the rest of the function readable.
const data = {
backgroundColor: el.children?.[0]?.innerText?.trim(),
textContent: el.children?.[1]?.children?.[0],
imageContent: el.children?.[1]?.children?.[1],
altText: el.children?.[1]?.children?.[2]?.innerText?.trim(),
primaryVariant: Boolean(el.children?.[2]?.innerText?.trim()),
};
if (data.primaryVariant) {
layout.classList.add('my-block--primary');
} else {
layout.style.background = `var(--spectrum-${data.backgroundColor})`;
}
Use BEM for class names
Use block__element--modifier naming for classes added inside a block. The block folder name is the BEM block; elements and modifiers are scoped under it.
.my-block { ... }
.my-block__heading { ... }
.my-block__image { ... }
.my-block--primary { ... }
.my-block__heading--large { ... }
The block root class (my-block) is set by loadBlock from the first class on the element. All additional classes added by init should follow BEM from there.
Known open questions
These were unresolved at the time this skill was written and should be verified before implementing:
- What is the testing convention for blocks — are there unit tests, and if so what does the test file look like?