with one click
component
Implementeer een Lit + TypeScript web component
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Menu
Implementeer een Lit + TypeScript web component
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Based on SOC occupation classification
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: