con un clic
component
Implementeer een Lit + TypeScript web component
Instalar con Codex o Claude Copia este prompt, pégalo en Codex, Claude u otro asistente, y deja que revise la página de la skill y la instale por ti.
Menú
Implementeer een Lit + TypeScript web component
Instalar con Codex o Claude Copia este prompt, pégalo en Codex, Claude u otro asistente, y deja que revise la página de la skill y la instale por ti.
Basado en la clasificación ocupacional SOC
Bouw applicaties met de web components van het NLDD Design System (@nldd/design-system, Nederlandse Digitale Dienst, Rijksoverheid). Triggers: @nldd/design-system, 'nldd-' tags, vragen over layout, sheets, popovers, modals, formulieren, toegankelijkheid, CSS-tokens of upgraden van dit systeem. NIET voor het ontwikkelen van het design system zelf (daarvoor: /component, /css).
Conventies voor translation keys (i18n microcopy)
Bewerk CHANGELOG.md — voeg release-notes/entries toe boven de laatste versie. Gebruik bij "changelog bijwerken", "release-notes schrijven", of het noteren van wijzigingen die semantic-release niet uit de commits haalt. Legt vast welke
Create and setup a git worktree with all necessary files and dependencies. Use when starting work on a new feature branch.
CSS-conventies voor design-system componenten — MECE breakpoints (expliciet per breakpoint, geen mobile-first overrides), at-rule nesting, lokale variabelen, comments
Manage multiple Storybook instances across worktrees. Use when starting, stopping, or checking status of Storybook dev servers.
| name | component |
| description | Implementeer een Lit + TypeScript web component |
| user-invocable | true |
| argument-hint | <component-naam> |
Implementeer een web component: $ARGUMENTS
| Aspect | Technologie |
|---|---|
| Framework | Lit (LitElement) |
| Taal | TypeScript (.ts) |
| Prefix | nldd- |
toggle-button.ts (geen prefix)nldd- prefix: nldd-toggle-buttonNLDDToggleButtonZoek in src/components/ of het component al bestaat.
| Situatie | Mode |
|---|---|
| Component bestaat niet | CREATE — nieuwe bestanden aanmaken |
| Component bestaat al | UPDATE — bestaande bestanden bijwerken |
Voorkeursvolgorde:
--components-{name}-* (component-specifiek)--semantics-* (betekenisvol)--primitives-* (alleen als backup)Naamconventies:
--primitives-{property}-{variant}-{scale}
bijv. --primitives-color-accent-750--semantics-{group}-{variant}-{state}-{element}-{element-variant}-{element-state}-{property}
bijv. --semantics-buttons-neutral-tinted-is-hovered-background-color--components-{component}-{variant}-{state}-{element}-{element-variant}-{element-state}-{property}
bijv. --components-checkbox-md-check-icon-size--context-{context}-{property}
Gedeelde variabelen voor communicatie tussen componenten. Niet gedefinieerd in settings.css.
bijv. --context-parent-background-color--_{variant}-{state}-{element}-{element-variant}-{element-state}-{property}
Interne variabelen binnen een component. Definieer defaults in :host.
bijv. --_background-color
Het {element}-segment is de volledige BEM-elementnaam, niet afgekort: --_disclosure-icon-margin-right, niet --_disclosure-margin-right. Laat het element-segment weg voor het root-block (--_background-color). Gebruik één generieke naam als de var door meerdere elementen gedeeld wordt (bijv. --_icon-size voor __start-icon én __end-icon).Primitives zijn basiswaarden — gebruik ze niet direct in componenten. Semantics geven context voor een groep componenten. Component variabelen zijn specifiek voor één component.
Zoek in src/assets/styles/settings.css:
grep -i "{component-naam}" src/assets/styles/settings.css
grep -i "controls.*min-size\|controls.*corner-radius" src/assets/styles/settings.css
grep -i "focus-ring" src/assets/styles/settings.css
src/components/{categorie}/{naam}/
{naam}.ts # Component class
{naam}.styles.ts # Styles
{naam}.template.ts # Render template
{naam}.i18n.ts # Vertalingen (optioneel, bij gebruikersgerichte tekst)
{naam}.stories.ts # Storybook stories
{naam}.test.ts # Tests
{naam}.ts:
/**
* NLDD Design System {DisplayName} Component (Lit + TypeScript)
*
* @element nldd-{naam}
* @attr {string} size - Component size: 'xs' | 'sm' | 'md' (standaard: 'md')
* @attr {boolean} disabled - Uitgeschakelde staat
*
* @slot - Default slot voor content
*
* @fires {event-naam} - Beschrijving (detail: { ... })
*/
import { LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { styles } from './{naam}.styles.ts';
import { template } from './{naam}.template.ts';
type Size = 'xs' | 'sm' | 'md';
@customElement('nldd-{naam}')
export class NLDD{PascalName} extends LitElement {
static override styles = styles;
@property({ type: String, reflect: true })
size: Size = 'md';
@property({ type: Boolean, reflect: true })
disabled = false;
override render() {
return template(this);
}
}
declare global {
interface HTMLElementTagNameMap {
'nldd-{naam}': NLDD{PascalName};
}
}
{naam}.styles.ts:
import { css } from 'lit';
import { unsafeCSS } from 'lit';
import { breakpoints } from '../../../assets/styles/breakpoints.ts';
const smMax = unsafeCSS(breakpoints.smMax);
const mdMin = unsafeCSS(breakpoints.mdMin);
const mdMax = unsafeCSS(breakpoints.mdMax);
const lgMin = unsafeCSS(breakpoints.lgMin);
export const styles = css`
/* # Host */
:host {
display: inline-block;
}
:host([hidden]) {
display: none;
}
:host([disabled]) {
opacity: var(--primitives-opacity-disabled);
pointer-events: none;
}
/* # Element */
.{naam} {
appearance: none;
border: none;
margin: 0;
padding: 0;
background: none;
font: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
container-name: layout-container;
container-type: inline-size;
}
:host([size="md"]) .{naam},
:host(:not([size])) .{naam} {
min-height: var(--semantics-controls-md-min-size);
border-radius: var(--semantics-controls-md-corner-radius);
}
/* ## Responsive — container queries (binnen layout-container) */
.{naam}__content {
@container layout-container (max-width: ${smMax}) {
/* sm: compact weergave */
}
@container layout-container (min-width: ${mdMin}) and (max-width: ${mdMax}) {
/* md */
}
@container layout-container (min-width: ${lgMin}) {
/* lg */
}
}
/* # Focus */
.{naam}:focus-visible {
outline: none;
}
.{naam}__indicator {
position: absolute;
inset: var(--primitives-space-4);
border-radius: calc(var(--semantics-controls-md-corner-radius) - var(--primitives-space-4) / 2);
background-color: transparent;
pointer-events: none;
}
.{naam}:focus-visible .{naam}__indicator {
box-shadow: var(--semantics-focus-ring-box-shadow);
outline: var(--semantics-focus-ring-outline);
}
/* # Toegankelijkheid */
@media (forced-colors: active) {
.{naam}:focus-visible .{naam}__indicator {
outline: 2px solid CanvasText;
}
}
`;
{naam}.template.ts:
import { html, TemplateResult } from 'lit';
import type { NLDD{PascalName} } from './{naam}.ts';
export function template(component: NLDD{PascalName}): TemplateResult {
return html`
<button class="{naam}"
type="button"
?disabled=${component.disabled}
>
<span class="{naam}__indicator"></span>
<slot></slot>
</button>
`;
}
{naam}.stories.ts:
args staat altijd vóór argTypes in de default exportstartIcon, fullWidth)name: het HTML attribuut in kebab-case (bijv. name: 'start-icon', name: 'full-width')table.defaultValue.summary: altijd invullen met de default waardedescription: korte Nederlandse beschrijvingcontrol: 'select' met options: ['(geen)', ...ICONS] plus mapping: { '(geen)': '' } — importeer ICONS uit ../../content/icon/icon.ts. Nooit een text input voor iconen.icon=, start-icon=, in stories én consumers) de alias-naam boven de canonieke naam als er een alias bestaat — bijv. harvest i.p.v. wheat, info i.p.v. info-circle, new-account i.p.v. person-circle-badge-plus. Aliassen zijn betekenisvoller en stabieler; ze staan in src/components/content/icon/icon-aliases.js.'(geen)' als label en mapping om dat naar de echte waarde te vertalen. Plaats '(geen)' als eerste element in options. In args staat de actual value ('' of undefined) — Storybook reverse-lookt via mapping welke label de huidige waarde representeert en toont die als geselecteerd in de UI. De render-functie ontvangt eveneens de actual value. Let op: bij opties met numerieke waarden (1, 2, ...) plaatst JS de integer-index keys altijd eerst in Object.keys, waardoor '(geen)' visueel onderaan de dropdown belandt; de selected-state werkt wel correct, dus accepteer dat als trade-off.
// String prop met '' als "geen waarde"
args: { variant: '' },
argTypes: {
variant: {
control: 'select',
options: ['(geen)', 'icon-and-text', 'text', 'icon'],
mapping: { '(geen)': '' },
table: { defaultValue: { summary: '(geen)' } },
},
},
// Number prop met undefined als "geen waarde"
args: { headingLevel: undefined },
argTypes: {
headingLevel: {
control: 'select',
options: ['(geen)', 1, 2, 3, 4, 5, 6],
mapping: { '(geen)': undefined },
table: { defaultValue: { summary: '(geen)' } },
},
},
args, argTypes, template-destructuring en HTML-attributen in de template gebruiken dezelfde volgorde, volgens de canon hieronder.Per component: pak alleen de keys die je gebruikt en zet ze in deze volgorde. De groepering is een mentaal model — de daadwerkelijke args/argTypes is een platte lijst.
[1. Visueel dominant]
variant, size, compact, color, background, layout, panes,
iconOnly, responsive, showItemLabels, inspectorAsSheet, sidebarAsSheet, noLogo
[2. Sizing]
resize, rows, width, minWidth, maxWidth, height, minHeight, fullWidth,
itemWidth, containerSize
[3. Space]
spacing, padding, paddingInline, paddingBlock, paddingTop, paddingRight,
paddingBottom, paddingLeft
+ smPadding…, mdPadding…, lgPadding…, layoutAreaXxxxPadding…
[4. Alignment and position]
horizontalAlignment, verticalAlignment, direction, orientation, placement,
labelAlignment, top, right, bottom, left, child
[5. Main content]
text, supportingText, overline, label, supportingLabel, optional,
placeholder, number, headingLevel,
icon, startIcon, endIcon, containerColor,
quote, attribution, cite, keys,
logoTitle, logoSubtitle, logoSupportingText1, logoSupportingText2, logoHref,
websiteTitle, websiteHref, backText, backHref, dismissText
[6. Key behavior]
control, expandable
[7. A11y]
accessibleLabel
[8. Elements]
showSearchButton, hideSpinButtons, hasDragHandle, maxItems
[9. Elements content]
showText, hideText, overflowText
[10. Elements A11y]
showAccessibleLabel, hideAccessibleLabel
[11. Behavior]
modeless, movable, stickyHeader, stickyFooter, hasContent, alwaysVisible,
showLoadMore, lazyLoad, collapseAnchor, contentPriority
[12. States]
selected, checked, indeterminate, open, valid, invalid, masked, readonly, current, disabled
[13. Form]
name, value, type, min, max, step, required, total,
autocomplete, noSpellcheck, href, target, method, action, novalidate
Open punt: type staat onder Form (HTML input type, vaakste betekenis). Voor segmented-control heeft het een andere semantiek (radio/checkbox-modus); kan later via een rename naar bijv. selectionMode opgelost worden.
import { html, nothing } from 'lit';
import './{naam}.ts';
import { ICONS } from '../../content/icon/icon.ts';
export default {
title: 'Components/{Categorie}/{DisplayName}',
component: 'nldd-{naam}',
tags: ['autodocs'],
parameters: {
componentSource: {
file: 'src/components/{categorie}/{naam}/{naam}.ts',
repository: 'https://github.com/MinBZK/storybook',
},
status: { type: 'stable' },
},
args: {
size: 'md',
startIcon: '',
fullWidth: false,
disabled: false,
},
argTypes: {
size: {
control: 'select',
options: ['xs', 'sm', 'md'],
description: 'Componentmaat',
table: { defaultValue: { summary: 'md' } },
},
startIcon: {
name: 'start-icon',
control: 'select',
options: ['(geen)', ...ICONS],
mapping: { '(geen)': '' },
description: 'Icoon voor de tekst',
table: { defaultValue: { summary: '(geen)' } },
},
fullWidth: {
name: 'full-width',
control: 'boolean',
description: 'Full width',
table: { defaultValue: { summary: false } },
},
disabled: {
control: 'boolean',
description: 'Uitgeschakelde staat',
table: { defaultValue: { summary: false } },
},
},
};
const Template = ({
size,
startIcon,
fullWidth,
disabled,
}: Record<string, unknown>) => html`
<nldd-{naam}
size=${size || nothing}
start-icon=${startIcon || nothing}
?full-width=${fullWidth}
?disabled=${disabled}
>Label</nldd-{naam}>
`;
export const Default = Template.bind({});
{naam}.test.ts:
import { describe, it, expect, afterEach } from 'vitest';
import { fixture, cleanup, waitForUpdate } from '../../../test-utils.ts';
import './{naam}.ts';
describe('nldd-{naam}', () => {
let el: HTMLElement;
afterEach(() => {
if (el) cleanup(el);
});
it('rendert zonder fouten', async () => {
el = await fixture('<nldd-{naam}></nldd-{naam}>');
await waitForUpdate(el);
expect(el.shadowRoot).not.toBeNull();
});
});
Zie /translation-keys skill voor alle conventies rond translation keys, types en implementatie.
Gebruik <nldd-spacer> voor ruimte tussen verschillende soorten componenten die elkaar direct opvolgen:
<!-- Vaste spacing op alle breakpoints -->
<nldd-spacer size="32"></nldd-spacer>
<!-- Per breakpoint anders -->
<nldd-spacer sm-size="16" md-size="24" lg-size="32"></nldd-spacer>
<!-- Vult beschikbare ruimte op -->
<nldd-spacer size="flexible"></nldd-spacer>
Beschikbare sizes: 2, 4, 6, 8, 10, 12, 16, 20, 24, 28, 32, 40, 44, 48, 56, 64, 80, 96, flexible
Per-viewport overrides: sm-size (max 640px), md-size (641–1007px), lg-size (min 1008px). Niet expliciet gezet → val terug op size.
Er is geen automatische formatter. Volg deze regels handmatig.
> niet /> voor void elements:<!-- GOED -->
<input class="checkbox__input" type="checkbox">
<!-- FOUT -->
<input class="checkbox__input" type="checkbox" />
.styles.ts)--_* vars (die staan bovenin :host):
box-sizing, display, position, inset / top / right / bottom / left, float, clearvisibility, opacity, z-indexmarginoutline, outline-offset, border, border-radius, box-shadowbackground, background-*cursor, pointer-eventswidth / min-width / max-width, height / min-height / max-height, overflowpaddingflex-*, grid-*, gap, align-*, justify-*, place-*, order, vertical-align, text-aligncolor, font / font-*, line-height, letter-spacing, text-decoration, text-overflow, white-space, contenttransition, transform, animation, appearance, isolation, -webkit-tap-highlight-color
Pseudo-elementen: content: '' mag bovenaan (vóór 1). Responsive breakpoint-@container/@media blijven genest, ná de properties van die rule.@container en @media met sm/md/lg) — genest in de element/:host rule. State/toegankelijkheid-@media (forced-colors, prefers-reduced-motion, hover) niet nesten — als los blok direct ná de element-rule die het wijzigt; géén aparte sectie ervoor--_*) bovenin :host, gevolgd door een lege regel die ze scheidt van de overige properties. Inclusief responsive overrides via @container nesting. Elementen gebruiken alleen var(--_foo), nooit fallbacks: niet var(--_foo, 100)--_* vars: in volgorde van eerste gebruik in de stylesheet (de rules staan zelf in Concentric volgorde, dus dit volgt daaruit). Niet concentric- of alfabetisch sorteren. Pas dezelfde canonieke volgorde toe in élk override-blok (:host([size=…]), :host([variant=…]), :host([expanded]…)): elk blok somt z'n subset in die volgorde op. Herordenen is risicoloos — declaratievolgorde heeft geen cascade-effectflex: 1), schrijf de losse properties/* # Section */): 2 lege regels ervoor, 1 erna/* ## Subsection */): 1 lege regel ervoor en erna/* GOED — CSS nesting */
.button {
display: inline-flex;
min-height: var(--_min-height);
@container (min-width: 641px) {
padding: var(--_md-padding);
}
}
/* FOUT — niet nesten */
.button { display: inline-flex; }
@container (min-width: 641px) {
.button { padding: var(--_md-padding); }
}
/* GOED — vars bovenin :host, lege regel, dan de rest */
:host {
--_min-height: var(--semantics-controls-md-min-size);
--_logo-width: var(--primitives-space-40);
@container layout-container (min-width: 641px) {
--_logo-width: var(--primitives-space-44);
}
display: inline-flex;
min-height: var(--_min-height);
}
.logo {
width: var(--_logo-width);
height: calc(var(--_logo-width) * 2);
}
/* FOUT — vars vermengd met properties zonder scheidingsregel */
:host {
display: inline-flex;
--_min-height: var(--semantics-controls-md-min-size);
min-height: var(--_min-height);
}
/* FOUT — lokale var op element ipv :host */
.logo {
--_logo-width: var(--primitives-space-40);
}
/* FOUT — fallback in var() */
.button {
min-height: var(--_min-height, 44px);
}
.template.ts)class staat altijd op dezelfde regel als het element--_size: 100%)${...}-interpolatie; de open- en sluittag staan dan op hun eigen regel. Zo blijven regels kort en tonen diffs alleen de gewijzigde inhoud, niet de hele tag-regel. Geldt voor losstaande elementen in de template-body. Een kort inline html-fragment binnen een expressie of ternary mag op één regel blijven (zie het voorbeeld hieronder); dat opsplitsen levert juist lelijke fragmenten op.<!-- GOED — class + meerdere attributen -->
<input class="checkbox__input"
type="checkbox"
.checked=${component.checked}
?disabled=${component.disabled}
@change=${component._handleChange}
>
<!-- GOED — class + één attribuut: attribuut op eigen regel -->
<div class="checkbox__box"
aria-hidden="true"
>
<!-- GOED — één attribuut zonder class: op één regel -->
<slot name="header"></slot>
<!-- GOED — child component in wrapper -->
<span class="checkbox__icon">
<nldd-icon name="check-mark-small"></nldd-icon>
</span>
<!-- GOED — element-content op een eigen regel -->
<p class="dialog__supporting-text">
${component.supportingText}
</p>
<!-- GOED — kort inline html-fragment in een ternary: mag op één regel -->
${component.hasBadge ? html`<span class="dialog__badge">${component.badge}</span>` : nothing}
<!-- FOUT — content inline op de tag-regel -->
<p class="dialog__supporting-text">${component.supportingText}</p>
<!-- FOUT — class op child component -->
<nldd-icon class="checkbox__icon" name="check-mark-small"></nldd-icon>
<!-- FOUT — class op aparte regel -->
<input
class="checkbox__input"
type="checkbox"
>
.stories.ts)Gebruik BEM (Block Element Modifier) + state classes:
.block /* Zelfstandig component */
.block__element /* Onderdeel van block */
.block--modifier /* Variant van block */
.pagination, .checkbox, .button)__ (.pagination__page-button)-- (.button--primary).block__element__subelementVarianten vs states:
button--primary, button--smlist-item.is-dragging, page-button.is-current<button class="button button--primary">
<div class="list-item is-dragging">
<button class="pagination__page-button is-current">
Slotted (light-DOM) content leeft in het document van de consument. Document-CSS (Tailwind Preflight, Bootstrap Reboot, een eigen host-reset) verslaat daardoor een component z'n ::slotted()-regels voor elke normale declaratie — ongeacht specificiteit. Zonder bescherming bloedt host-styling door en breekt de consistentie tussen overheidssites.
Getypeerde/eigen slots (specifiek element of [slot=…] waarvan de DS de styling bezit: h1, a, p, img, [slot="title"], een native <select>) → gebruik slottedReset, en bij tekst ook inheritedTextReset, uit assets/styles/slotted-reset.js. Zet de reset vooraan en je eigen declaraties erná, elk !important (anders verslaat all: revert !important je eigen waarden):
import { slottedReset, inheritedTextReset } from '../../../assets/styles/slotted-reset.js';
::slotted(:not([slot])) {
${slottedReset}
${inheritedTextReset}
color: var(--semantics-content-color) !important;
font: var(--_font) !important;
}
Andere regels die hetzelfde slotted element raken (:hover, @media, een specifiekere override) moeten dan óók !important.
Eigen shadow-tekst (tekst die het component zélf rendert: labels, waarden, ::before-content) → zet ${inheritedTextReset} op :host, als guard-blok direct ná de --_* vars en vóór de overige properties. Dat blokkeert geërfde host-typografie (letter-spacing/text-transform/text-align) die anders via body → host → :host in je shadow-tekst lekt. Géén all: revert op :host — dat zou de eigen layout van het component slopen; alleen inheritedTextReset.
:host {
--_foo: …;
${inheritedTextReset}
display: …;
}
Generieke ::slotted(*) en custom-element/icon-slots (::slotted(nldd-*), [slot="icon"]) → met rust laten. De reset zou willekeurige content (vaak een ander nldd-component met eigen :host) naar UA terugzetten; die inhoud hardent zichzelf. Leg hooguit een losse structuur-prop (flex-shrink, display) !important op waar een host functionaliteit zou breken.
Document-level componenten (*.css zoals rich-text.css via global.css) zijn een ánder geval: hun descendant-selectors (nldd-rich-text h1, specificiteit 0,0,2) verslaan Preflight's kale h1{} (0,0,1) al op specificiteit. Geen reset/!important nodig voor de Preflight-case.
text-align zit in inheritedTextReset (gelockt op start, RTL-veilig) — de host mag niet centreren of justifyen. Heeft een component alignment nodig, bied het expliciet aan: :host([align="center"]) ::slotted(…) { text-align: center !important }.
CSS:
!important — behalve in de slotted-reset (::slotted()) en de host-text-reset (:host) (zie SLOTTED CONTENT & HOST-CSS ISOLATIE)cursor: pointervar(--primitives-opacity-disabled)Accessibility:
@media (prefers-reduced-motion)@media (forced-colors: active)TypeScript:
declare global blockTaal:
color, behavior, center, gray, -ize), niet BritsShadow DOM:
part attributen op shadow DOM elementenVerificatie: