Coding Guidelines
February 18, 2026 ยท View on GitHub
General
-
Clear is better than clever. Optimize for simple, readable code first.
-
Prefer longer, more descriptive names, over shorter names.
-
Use web-compatible, full URLs as import specifiers, including file extensions:
[!TIP] DO
import * as foo from './foo.js';[!WARNING] DON'T
import * as foo from './foo'; -
We use TypeScript and have strict compiler options turned on. Do not change them.
-
Prefer the
unknowntype overany. -
Prefer the
objecttype overObject. -
Prefer explicitly defining a function shape over using
Functionas a type. -
Don't use TypeScript
namespace. -
Prefer simple union types over
enum. -
Internal API (properties, methods, getters, setters) should be prefixed with an underscore (
_). -
Use the
readonlymodifier for properties that should not be reassigned. -
Specify return types for functions and methods explicitly rather than relying on inference, unless the type is obvious or causes unnecessary clutter in the source code.
Components
-
As a rule of thumb new components should be placed in the components sub-directory following the pattern below:
src/components/[component]/[component].ts -
Stick to a single export from the component file, that is the component class itself.
-
Testing file(s) should be also in the same directory following the
[component-name].spec.tspattern. -
CSS styles and theming assets usually live in
src/components/[component]/themes/*. -
Anything else is a fair game as long as it has consistent and meaningful naming.
-
When adding a new component or modifying an existing one, stick to the following code structure. Use region comments to clearly delineate sections of the component.
export default class IgcFooBarComponent extends LitElement {
/** Static members */
/**
* Each component should define a valid custom element tag name.
*/
public static readonly tagName = 'igc-foo-bar';
public static override styles = [styles];
/**
* Since Ignite UI web components are not self-registering by themselves,
* each component should implement the `register` static method.
* The `registerComponent` call will add the component to the custom elements
* registry (if not already present) and all its dependent components.
*/
public static register(): void {
registerComponent(IgcFooBarComponent, ...);
}
//#region Internal state and properties
private _foo = 0;
private readonly _controller = addSomeController(this);
@state()
protected _invalid = false;
@query('input')
private _inputElement!: HTMLInputElement;
@queryAssignedElements({ selector: IgcFooChildComponent.tagName })
protected _fooChildren!: Array<IgcFooChildComponent>;
protected get _bar(): number {
return this._foo * 2;
}
//#endregion
//#region Public attributes and properties
/**
* The value of the component.
* @attr
*/
@property()
public value = '';
/**
* Determines whether the component is disabled.
* @attr
*/
@property({ type: Boolean, reflect: true })
public disabled = false;
/** Returns whether the component is complete. */
public get complete(): boolean {
return this._invalid;
}
//#endregion
constructor() {
super();
this.addEventListener('input', this._handleInput);
}
//#region Lit lifecycle methods
public override connectedCallback(): void {
super.connectedCallback();
// ...
}
protected override willUpdate(changedProperties: PropertyValues<this>): void {
// Compute derived state before rendering
if (changedProperties.has('value')) {
this._invalid = !!this.value;
}
}
protected override update(changedProperties: PropertyValues<this>): void {
// Handle side effects or sync state with DOM access
if (changedProperties.has('disabled')) {
this._updateAriaDisabled();
}
super.update(changedProperties);
}
protected override firstUpdated(changedProperties: PropertyValues<this>): void {
// ...
}
//#endregion
//#region Event handlers
private _handleInput(event: InputEvent): void {
// ...
}
//#endregion
//#region Internal API
private _resetState(): void {
// ...
}
private _updateAriaDisabled(): void {
// ...
}
protected _updateState(): void {
// ...
}
//#endregion
//#region Public API
/** Resets the component to its initial state. */
public reset(): void {
this._resetState();
}
//#endregion
protected _renderContainer() {
// ...
}
protected _renderInput() {
// ...
}
protected override render() {
return html`${this._renderInput()}${this._renderContainer()}`;
}
}
/**
* TypeScript will infer the class of an HTML element returned from certain DOM APIs based on the tag name.
* Add the `HTMLElementTagNameMap` for each component so it can be included in the `.d.ts` typings of the library
* and it's properly type-checked.
*/
declare global {
interface HTMLElementTagNameMap {
'igc-foo-bar': IgcFooBarComponent;
}
}
-
Component Structure Guidelines:
- Static members come first (no region fence needed).
- Use
//#region Internal state and propertiesfor all internal reactive and non-reactive state, controllers, DOM queries, and internal getters/setters. - Use
//#region Public attributes and propertiesfor all public reactive properties and read-only getters. - Constructor follows the public properties section (no region fence).
- Use
//#region Lit lifecycle methodsforconnectedCallback,disconnectedCallback,willUpdate,update,firstUpdated, etc. - Use
//#region Event handlersfor all event handler methods. - Group internal methods in appropriately named regions based on their behavior or function (e.g.,
//#region Keyboard navigation,//#region Form integration,//#region Internal API). - Use
//#region Public APIfor all public methods. - Rendering methods and the
render()override come last (no region fence needed).
-
Computed and Derived State:
Prefer using Lit's lifecycle methods (
updateorwillUpdate) over the@watchdecorator for handling property changes and computing derived state.- Use
update()when you need DOM access or want to trigger side effects. - Use
willUpdate()for computing derived state before rendering. - The
@watchdecorator should be avoided in new code.
[!TIP] DO
protected override willUpdate(changedProperties: PropertyValues<this>): void { if (changedProperties.has('value')) { this._invalid = this.value.length < this.minLength; } } protected override update(changedProperties: PropertyValues<this>): void { if (changedProperties.has('disabled')) { this._updateAriaAttributes(); } super.update(changedProperties); }[!WARNING] DON'T
@watch('value') protected valueChange(): void { this._invalid = this.value.length < this.minLength; } - Use
-
After adding new component(s) to the library, make sure to export them from the entry point of the package:
// in src/index.ts
/* ... */
export { default as IgcFooBarComponent } from './components/foobar/foobar.js';
/* ... */
Imports
-
Organize imports in the following order, with blank lines between groups:
- Lit imports (
lit,lit/decorators.js,lit/directives/*) - Third-party library imports
- Internal utilities and controllers (
../common/*) - Component imports
- Type imports last (if not inline)
[!TIP] DO
import { html, LitElement } from 'lit'; import { property, query } from 'lit/decorators.js'; import { addThemingController } from '../../theming/theming-controller.js'; import { addSlotController } from '../common/controllers/slot.js'; import { registerComponent } from '../common/definitions/register.js'; import IgcIconComponent from '../icon/icon.js'; import { styles } from './themes/badge.base.css.js'; import type { StyleVariant } from '../types.js'; - Lit imports (
Controllers
-
Controllers are reusable pieces of logic that hook into a component's lifecycle. Use controllers from
src/components/common/controllers/for common functionality:addThemingController- Required for theme supportaddSlotController- For managing slotted contentaddInternalsController- For ElementInternals and ARIA managementaddKeybindings- For keyboard navigation- And others as needed
-
Controllers should be stored as
readonlyclass fields and initialized inline:private readonly _slots = addSlotController(this, { slots: setSlots(), onChange: this._handleSlotChange, });
Slots
-
Use slots to allow content composition. Document all slots with
@slotJSDoc tags. -
The default slot typically holds the main content.
-
Named slots serve specific purposes (e.g.,
prefix,suffix,header). -
Use
addSlotControllerto react to slot content changes:private readonly _slots = addSlotController(this, { slots: setSlots('prefix', 'suffix'), onChange: this._handleSlotChange, }); private _handleSlotChange(): void { this._hasPrefix = this._slots.hasAssignedElements('prefix'); }
Shadow DOM and CSS Parts
-
All components use Shadow DOM for style encapsulation (mode:
'open'by default). -
Expose internal elements as CSS parts using the
partattribute to allow external styling:/** * @csspart base - The main container * @csspart input - The native input element */ protected override render() { return html` <div part="base"> <input part="input" /> </div> `; } -
Use the
partMapdirective for conditional parts:import { partMap } from '../common/part-map.js'; protected override render() { return html` <div part=${partMap({ base: true, invalid: this._invalid })}> ... </div> `; } -
For delegating focus to internal elements, use the
@shadowOptionsdecorator:import { shadowOptions } from '../common/decorators/shadow-options.js'; @shadowOptions({ delegatesFocus: true }) export default class IgcInputComponent extends LitElement { // Focus is automatically delegated to the first focusable element }
Accessibility
Accessibility is a first-class requirement for all components.
-
Always test accessibility - Components must pass a11y audits in tests.
-
Use semantic HTML elements where appropriate (
<button>,<input>, not generic<div>with click handlers). -
Provide proper ARIA attributes using
addInternalsController:private readonly _internals = addInternalsController(this, { initialARIA: { role: 'button', ariaLabel: 'Close', }, }); // Update ARIA dynamically this._internals.setARIA({ ariaExpanded: `${this.open}` }); -
Keyboard navigation is required for interactive components:
- Tab navigation should work naturally
- Arrow keys for list navigation
- Enter/Space for activation
- Escape to close/cancel
- Home/End for first/last item
-
Use
addKeybindingsfor keyboard interaction:import { addKeybindings, arrowDown, arrowUp, enterKey } from '../common/controllers/key-bindings.js'; constructor() { super(); addKeybindings(this) .set(arrowDown, this._navigateNext) .set(arrowUp, this._navigatePrevious) .set(enterKey, this._handleActivate); } -
Ensure focus management - visible focus indicators and logical focus order.
-
Provide text alternatives for non-text content.
-
Meet WCAG 2.1 Level AA standards minimum.
Testing
All components must include comprehensive tests in [component-name].spec.ts.
-
Required tests:
- Accessibility audit (mandatory):
it('passes the a11y audit', async () => { const el = await fixture<IgcComponentComponent>( html`<igc-component></igc-component>` ); await expect(el).shadowDom.to.be.accessible(); await expect(el).to.be.accessible(); }); - Default initialization
- Property/attribute setting and reflection
- Event emission
- User interactions (clicks, keyboard)
- Edge cases
- Accessibility audit (mandatory):
-
Use
defineComponents()in thebefore()hook to register components:import { defineComponents } from '../common/definitions/defineComponents.js'; describe('Component', () => { before(() => { defineComponents(IgcComponentComponent); }); // tests... }); -
Use
elementUpdated()after programmatic changes:element.value = 'new value'; await elementUpdated(element); expect(element.value).to.equal('new value'); -
Test both Light DOM and Shadow DOM:
expect(element).dom.to.equal('<igc-component value="test"></igc-component>'); expect(element).shadowDom.to.equal('<div part="base">...</div>');
Properties and Attributes
-
Property names should always be
camelCasedwhile the backing attribute, if present, should bekebab-cased. A special case are properties/attributes that mimic the standard HTML attributes, such asreadOnly/readonly,minLength/minlength, etc.It is encouraged to explicitly specify the kebab cased attribute name in the
@propertydecorator for such properties.[!TIP] DO
/** * Controls the orientation of the header. * @attr */ @property({ attribute: 'header-orientation' }) public headerOrientation: 'vertical' | 'horizontal' = 'horizontal';[!WARNING] DON'T
/** * Controls the orientation of the header. * @attr */ @property({ attribute: 'headerOrientation' }) public headerOrientation: 'vertical' | 'horizontal' = 'horizontal'; -
For a boolean property to be configurable from an attribute, it must default to false. If it defaults to true, you cannot set it to false from markup, since the presence of the attribute, with or without a value, equates to true. This is the standard behavior for attributes in the web platform.
If this behavior doesn't fit your use case, there are a couple of options:
- Change the property name so it defaults to false.
- Use a string-valued or number-valued attribute instead.
[!TIP] DO
/** * Enables/disables user interaction with the component. * @attr */ @property({ type: Boolean, reflect: true }) public disabled = false;[!WARNING] DON'T
/** * Enables/disables user interaction with the component. * @attr */ @property({ type: Boolean, reflect: true }) public enabled = true; -
Reflecting properties to attributes should be done sparingly. As a general guideline, primitive properties related to accessibility and/or styling should be reflected.
Do not reflect properties of type object or array.
-
For complex types (objects, arrays, functions), use
attribute: falseto prevent Lit from attempting to serialize them to attributes:[!TIP] DO
/** * Configuration object for the component. */ @property({ attribute: false }) public config: ComponentConfig = {}; /** * Collection of items to display. */ @property({ attribute: false }) public items: Array<Item> = [];[!WARNING] DON'T
// This will cause issues - objects can't be attributes @property() public config: ComponentConfig = {};
Custom Events
-
Events are the standard way that elements communicate changes. These changes typically occur due to user interaction. As such, components should emit events only in response to an user interaction, not an API invocation (property changed, method called).
-
In order to provide good TypeScript typings, components that emit custom events should derive from the
EventEmitterMixinclass and provide a type map for their events, which is passed to the mixin./** * FooBar events */ export interface IgcFooBarEventMap { igcFoo: CustomEvent<string>; igcBar: CustomEvent<void>; /* ... */ } export default class IgcFooBarComponent extends EventEmitterMixin< IgcFooBarEventMap, Constructor<LitElement> >(LitElement) { /* ... */ } -
Custom event names are
camelCasewith an igc prefix. Any cancelable events usually have an -ing suffix.export interface IgcFooBarEventMap { igcStateUpdating: CustomEvent<Record<string, unknown>>; // Cancelable igcStateChange: CustomEvent<Record<string, unknown>>; /* ... */ } -
Calling
EventEmitterMixin.emitEventwithout modifying theeventInitDictparameter dispatches events that are non-cancelable, composed and bubble up the ancestor tree. -
For cancelable events (typically
-ingsuffix), check the return value to determine if the event was canceled:if (!this.emitEvent('igcOpening', { cancelable: true, detail: data })) { return; // Event was canceled, abort operation } // Proceed with operation
Form Integration
Components that participate in forms must use the FormAssociatedRequiredMixin and implement form-related behavior.
-
Form-associated components (inputs, selects, etc.) should:
- Extend from
FormAssociatedRequiredMixin - Manage a form value via
createFormValueState - Implement validation if needed
- Handle form reset and restore
import { FormAssociatedRequiredMixin } from '../common/mixins/form-associated-required.js'; import { createFormValueState } from '../common/mixins/form-value.js'; export default class IgcInputComponent extends FormAssociatedRequiredMixin( LitElement ) { protected override readonly _formValue = createFormValueState(this, { initialValue: '', }); protected override get __validators() { return [ // Add validators here ]; } } - Extend from
-
The
FormValueinstance provides:setValueAndFormState(value)- Updates both the component's value and the form's datavaluegetter/setter - Accesses the value with appropriate transformersdefaultValuegetter/setter - Manages the default value for form reset
private _handleInput(event: InputEvent): void { const value = (event.target as HTMLInputElement).value; // Updates both value and form state this._formValue.setValueAndFormState(value); } // Direct value access (applies transformers) public get value(): string { return this._formValue.value; } public set value(val: string) { this._formValue.value = val; }
Performance
-
Avoid unnecessary re-renders:
- Implement
shouldUpdate()when you need to prevent updates based on specific conditions - Use
@state()for internal reactive state, not@property() - Check
changedProperties.has()in lifecycle methods to avoid unnecessary work
- Implement
-
Optimize expensive operations:
- Use
cache()directive for expensive template computation - Use
ifDefined()for optional attributes - Use
live()directive for two-way binding scenarios
import { cache } from 'lit/directives/cache.js'; import { ifDefined } from 'lit/directives/if-defined.js'; protected override render() { return html` <input type=${ifDefined(this.type)} .value=${this.value} /> ${cache(this._renderExpensiveContent())} `; } - Use
-
Avoid memory leaks:
Event listeners added in templates using
@eventsyntax or directly on component instances are automatically managed by Lit and do not require manual cleanup.Only event listeners added dynamically (in
connectedCallback(), other lifecycle methods, or event handlers) need explicit cleanup:import { addSafeEventListener } from '../common/util.js'; constructor() { super(); // addSafeEventListener prevents errors in SSR contexts // where addEventListener may not be available addSafeEventListener(this, 'click', this._handleClick); } // For dynamic listeners, clean up in disconnectedCallback private _handler = this._handleEvent.bind(this); public override connectedCallback(): void { super.connectedCallback(); document.addEventListener('resize', this._handler); } public override disconnectedCallback(): void { document.removeEventListener('resize', this._handler); super.disconnectedCallback(); }
Common Pitfalls
1. Forgetting to call super() in lifecycle methods
When overriding lifecycle methods, always call the super method:
Warning
protected override update(changedProperties: PropertyValues<this>): void {
// Do work...
super.update(changedProperties); // Don't forget!
}
2. Mutating objects/arrays directly
Lit cannot detect mutations to objects or arrays. Always create new instances:
Warning
// BAD - Lit won't detect the change
this.items.push(newItem);
// GOOD - Lit detects the new array reference
this.items = [...this.items, newItem];
3. Accessing Shadow DOM too early
Shadow DOM elements are not available in constructor() or early lifecycle methods. Use firstUpdated() or later:
Warning
// BAD - _inputElement is undefined
constructor() {
super();
this._inputElement.focus(); // Error!
}
// GOOD
protected override firstUpdated(): void {
this._inputElement.focus(); // Works
}
4. Not handling async operations properly
When dealing with async operations in lifecycle methods, be careful about component state:
Tip
protected override async update(changedProperties: PropertyValues<this>): Promise<void> {
if (changedProperties.has('data')) {
this._loading = true;
await this._loadData();
this._loading = false;
}
super.update(changedProperties);
}
5. Over-reflecting properties
Not every property needs to be reflected to an attribute. Only reflect when:
- It's a primitive type
- It affects styling (CSS attribute selectors)
- It's needed for accessibility
6. Forgetting theming controller
All components must include the theming controller in the constructor:
Warning
constructor() {
super();
addThemingController(this, all); // Required for theme switching!
}
7. Misunderstanding event listener cleanup
Lit automatically manages event listeners added in templates or on component instances. You only need to clean up listeners added dynamically:
Tip
// NO CLEANUP NEEDED - Lit handles these automatically
protected override render() {
return html`<button @click=${this._handleClick}>Click</button>`;
}
// NO CLEANUP NEEDED - Lit manages component instance listeners
constructor() {
super();
this.addEventListener('focus', this._handleFocus);
}
// CLEANUP REQUIRED - Dynamic external listeners
private _handler = this._handleResize.bind(this);
public override connectedCallback(): void {
super.connectedCallback();
window.addEventListener('resize', this._handler);
}
public override disconnectedCallback(): void {
window.removeEventListener('resize', this._handler);
super.disconnectedCallback();
}
// addSafeEventListener prevents SSR errors
constructor() {
super();
// Safe in SSR - won't error if addEventListener is unavailable
addSafeEventListener(this, 'pointerdown', this._handlePointer);
}
API Documentation
-
API documentation is written by following standard JSDoc tags and idioms.
Both TypeDoc and @custom-elements-manifest/analyzer are able to deduce most of the API by themselves. So tags such as
@param,@returns, etc. are not required.The same goes for
@abstract,@static,@private,@protectedand related members since the documentation tools get this information directly from the TypeScript source code. -
For documenting things like CSS shadow parts, CSS custom properties and available slots, please check the official guidelines of the CEM analyzer.
-
When documenting your code, put any JSDoc tags after the description of what the things does
[!TIP] DO
/** * Enables/disables user interaction with the component. * @attr */ @property({ type: Boolean, reflect: true }) public disabled = false; /** * An avatar component is used as a representation of a user identity * typically in a user profile. * * @element igc-avatar * * @slot - Renders an icon inside the default slot. * * @csspart base - The base wrapper of the avatar. * @csspart initials - The initials wrapper of the avatar. * @csspart image - The image wrapper of the avatar. * @csspart icon - The icon wrapper of the avatar. */ export default class IgcAvatarComponent extends LitElement {}[!WARNING] DON'T
/** * @attr * Enables/disables user interaction with the component. */ @property({ type: Boolean, reflect: true }) public enabled = true; -
When some API is deprecated, make sure to add a
@deprecatedtag with explanation when it was deprecated and what to use instead (if any). The deprecated message follows the following format:@deprecated since [SemVer]. Use the `[new API]` [type] instead.[!TIP] DO
/** * Updates the state of the component. * * @deprecated since 1.2.3. Use the `setState()` method instead. */ public updateState(state: T) {};[!WARNING] DON'T
/** * @deprecated - Refer to the changelog for a migration guide. * * Updates the state of the component. */ public updateState(state: T) {};
Changelog
- When adding a new component or fixing a bug make sure to update the CHANGELOG file with the relevant changes.
Storybook
All components should have a corresponding Storybook story in stories/[component-name].stories.ts.
- Stories provide interactive examples and documentation for components.
- Use Storybook controls to make all public properties configurable.
- Include multiple stories showcasing different component states and configurations.
import type { Meta, StoryObj } from '@storybook/web-components-vite';
import { html } from 'lit';
import { IgcBadgeComponent, defineComponents } from 'igniteui-webcomponents';
defineComponents(IgcBadgeComponent);
const metadata: Meta<IgcBadgeComponent> = {
title: 'Badge',
component: 'igc-badge',
argTypes: {
variant: {
options: ['primary', 'info', 'success', 'warning', 'danger'],
control: { type: 'select' },
},
},
args: { variant: 'primary' },
};
export default metadata;
export const Basic: StoryObj = {
render: (args) => html`<igc-badge .variant=${args.variant}>Badge</igc-badge>`,
};
Resources
- Project Documentation: README.md
- Lit Documentation: lit.dev
- Web Components: MDN Web Components
- Accessibility: WCAG Guidelines
- TypeScript: TypeScript Handbook
Getting Help
- Review existing components in
src/components/for patterns and examples - Read the LLM Skills for guided workflows
- Ask questions in pull request reviews
Checklist for New Components
Before submitting a PR for a new component, ensure:
- Component follows the standard structure with region fences
- All internal APIs prefixed with underscore (
_) - Theming controller added in constructor
- Accessibility tested and passing
- All properties properly documented with JSDoc
- Events use EventEmitterMixin with type map
- CSS parts exposed and documented
- Slots documented with
@slottags - Comprehensive tests including a11y audit
- Storybook story created with controls
- Component exported from
src/index.ts - CHANGELOG updated
- No TypeScript errors or warnings
- Code formatted (auto-formatted on save)