A proposal for extending Lit with declarative, inheritable, composable features
This proposal introduces Composable Feature-Centric Controllers — a pattern that extends Lit's ReactiveController concept to enable declarative composition of behaviors through inheritance, while maintaining full integration with Lit's reactive property system.
The system allows component authors to:
- Provide reusable features (controllers) at any level in a class hierarchy
- Configure or disable inherited features in subclasses
- Define reactive properties within features that seamlessly merge into the host component
- Compose multiple concerns (ripple/pulse feedback, theming, dismissal workflows) without deep mixin chains
Integration Goal: The architecture demonstrated in this POC (LitCore, LitFeature, decorators, and FeatureManager) would be integrated directly into LitElement and ReactiveElement, making features a native part of Lit's component model rather than a separate library.
A Feature is a specialized ReactiveController that:
- Implements
ReactiveController- participates in host lifecycle - Declares reactive properties - properties that merge into the host's property system
- Encapsulates single-responsibility behavior - feedback, theming, dismissal, etc.
- Is inheritable and configurable - subclasses can reconfigure or disable features
- Supports standard JavaScript class inheritance - features can extend other feature classes or base classes, inheriting properties and methods for more granular control
- Declarative Composition: Features are declared via class decorators or static getters, not imperatively instantiated
- Inheritance-Aware: Features flow down class hierarchies and can be reconfigured at each level
- Property Integration: Feature properties become host properties automatically
- Single Responsibility: Each feature manages one concern (ripple/pulse feedback, theme, dismissal)
- Inter-Feature Communication: Features can discover and communicate with other features on the same host
- Class-Based Extensibility: Features leverage standard JavaScript class inheritance, allowing custom feature subclasses with specialized properties and methods for more granular control
Features support standard JavaScript class inheritance, enabling developers to create feature subclasses that extend base features with specialized properties and methods. This provides another vector for fine-grained control while maintaining all the benefits of the feature system.
Example: Extending BaseDismissFeature
// Base feature in library
export class BaseDismissFeature extends LitFeature {
@property({ type: Boolean, reflect: true })
dismissible = true;
dismiss(): void { /* ... */ }
}
// Custom subclass in application code
export class AnalyticsDismissFeature extends BaseDismissFeature {
@property({ type: String })
dismissSource: 'manual' | 'auto' = 'manual';
override dismiss(): void {
this.dismissSource = 'manual';
super.dismiss();
}
trackDismiss(): void { /* ... */ }
}
// Use custom feature in component
@provide('Dismiss', { class: AnalyticsDismissFeature })
class CustomNotice extends LitCore {
declare Dismiss: AnalyticsDismissFeature;
declare dismissible: boolean;
declare dismissSource: 'manual' | 'auto';
}Benefits of Feature Inheritance:
- Composition over duplication - Subclass features reuse base feature logic rather than duplicating it
- Specialized variants - Create custom feature variants for specific use cases without modifying the base feature
- Gradual enhancement - Layer functionality by extending existing features incrementally
- Maintains integration - Feature properties and lifecycle management work seamlessly in subclasses
This POC implements a small set of demo features used by the showcase:
Purpose: Adds click feedback with a material-style ripple.
Properties:
rippling: boolean- Reflects when the ripple animation is active
Demonstrates:
- Feature-provided styles
- Host event wiring
- Minimal reactive property surface
Purpose: Adds a pulsing attention state.
Properties:
pulsing: boolean- Toggles CSS animation
Methods:
togglePulse()- Convenience toggle
Demonstrates:
- Config-driven defaults
- CSS animation control via reactive properties
Purpose: Theme management with system preference support.
Properties:
theme: 'light' | 'dark' | 'auto'colors: ThemeColors- Computed palettesystemTheme: 'light' | 'dark'
Methods:
toggleTheme()getResolvedTheme()
Demonstrates:
- Decorator + static property declarations
- Lifecycle hooks (
connectedCallback,updated) - CSS variable propagation
Purpose: Manual, timed, and swipe-based dismissal using multi-level feature inheritance.
Chain:
BaseDismissFeature→AutoDismissFeature→SwipeDismissFeature
Demonstrates:
- Feature inheritance in practice
- Host event listeners for gesture handling
- Custom events for dismissal source tracking
┌─────────────────────────────────────────────────────────────┐
│ LitElement │
│ │ │
│ ↓ │
│ LitCore │
│ ┌──────────────────┴──────────────────┐ │
│ │ │ │
│ FeatureManager Feature Resolver │
│ │ │ │
│ ↓ ↓ │
│ Feature Instances ←──────────── Feature Snapshot │
│ │ (merged config) │
│ ↓ │
│ LitFeature (base) │
│ │ │
│ ├─── RippleFeature │
│ ├─── PulseFeature │
│ ├─── ThemeFeature │
│ └─── BaseDismissFeature → AutoDismissFeature → SwipeDismissFeature │
└─────────────────────────────────────────────────────────────┘
- Entry point for feature-enabled components
- Hosts a
FeatureManagerinstance - Overrides
finalize()to merge feature properties into component properties - Extends lifecycle methods to propagate to features
Key Methods:
static finalize(): void {
// Merge feature properties into host properties
const snapshot = resolveFeatureSnapshot(this);
this.properties = {
...superProps,
...snapshot.properties, // ← Feature properties merged here
...ownProps
};
super.finalize();
}
connectedCallback(): void {
this.featureManager.processLifecycle('beforeConnectedCallback');
super.connectedCallback();
this.featureManager.processLifecycle('connectedCallback');
this.featureManager.processLifecycle('afterConnectedCallback');
}- Base class for all features
- Creates property proxies that sync feature properties with host properties
- Implements
ReactiveControllerinterface - Manages internal property storage and observation
Key Responsibilities:
- Property proxy creation: Getting/setting feature properties reads/writes host properties
- Property observation: Tracks changes and triggers host updates
- Lifecycle participation: Receives lifecycle callbacks via
ReactiveController
Property Synchronization:
private _createPropertyObserver(propertyName: string): void {
Object.defineProperty(this, propertyName, {
get() {
return feature.getInternalValue(propertyName);
},
set(newValue: unknown) {
// Write to host's reactive property
hostRecord[propertyName] = newValue;
// Trigger Lit's update cycle
this.host.requestUpdate(propertyName, oldValue);
}
});
}- Service that manages feature lifecycle
- Instantiates features based on resolved snapshot
- Propagates host lifecycle events to all features
- Handles feature registration and cleanup
Initialization Flow:
private _initializeFeatures(): void {
const { provides, configs } = resolveFeatureSnapshot(this.hostConstructor);
Object.entries(provides).forEach(([name, definition]) => {
if (configs[name] === 'disable') return;
// Merge configs from inheritance chain
const finalConfig = merge({}, defaultConfig, userConfig);
const instance = new FeatureClass(this.host, finalConfig);
// Store on host: this.FeatureName = instance
this.host[name] = instance;
});
}- Resolves feature definitions across inheritance chains
- Merges configs from parent classes with child overrides
- Caches resolved snapshots for performance
- Handles property disabling and overrides
Resolution Algorithm:
1. Walk inheritance chain (bottom-up, excluding LitElement)
2. Collect all @provide decorators and static provide getters
3. Collect all @configure decorators and static configure getters
4. Merge configs using lodash.merge (deep merge)
5. Apply property overrides/disables
6. Create final snapshot with:
- provides: Map of feature name → definition
- configs: Map of feature name → merged config
- properties: Merged property declarations
7. Cache snapshot on constructor
Declares that a class provides a feature:
@provide('Theme', {
class: ThemeFeature,
config: { defaultTheme: 'light', respectSystemTheme: true }
})
export class ThemedCard extends LitCore {}Configures or disables an inherited feature:
@configure('Theme', {
config: { defaultTheme: 'dark' },
properties: { systemTheme: 'disable' }
})
export class DarkCard extends ThemedCard {}
// Or disable entirely:
@configure('Pulse', 'disable')
export class NoPulseBadge extends LitCore {}Declares reactive properties within features:
export class ThemeFeature extends LitFeature {
@property({ type: String, reflect: true })
theme: 'light' | 'dark' | 'auto' = 'light';
}Inheritance Chain:
// Tier 1: Provide click feedback
@provide('Ripple', { class: RippleFeature })
class SimpleButton extends LitCore {
declare Ripple: RippleFeature;
declare rippling: boolean;
}
// Tier 2: Provide theme with configuration overrides
@provide('Theme', { class: ThemeFeature, config: { defaultTheme: 'auto' } })
class ThemedPanel extends LitCore {
declare Theme: ThemeFeature;
declare theme: 'light' | 'dark' | 'auto';
}
// Tier 3: Swap in different dismiss feature classes
@provide('Dismiss', { class: BaseDismissFeature })
class BasicNotification extends LitCore {
declare Dismiss: BaseDismissFeature;
declare dismissed: boolean;
}
@provide('Dismiss', { class: AutoDismissFeature, config: { autoDismiss: true } })
class AutoNotification extends LitCore {
declare Dismiss: AutoDismissFeature;
declare dismissed: boolean;
}
@provide('Dismiss', { class: SwipeDismissFeature, config: { swipeToDismiss: true } })
class SwipeNotification extends LitCore {
declare Dismiss: SwipeDismissFeature;
declare dismissed: boolean;
}At runtime:
const notice = new SwipeNotification();
// All feature instances available:
notice.Dismiss.dismiss();
// All feature properties available on host:
notice.dismissed = false;The goal is to integrate this pattern directly into Lit's ReactiveElement and LitElement, making features a native capability rather than a separate framework.
Add feature resolution to finalize():
protected static finalize() {
// ... existing property resolution ...
// NEW: Resolve and merge feature properties
if (this.hasOwnProperty('provide') || this.hasOwnProperty('configure')) {
const featureSnapshot = this._resolveFeatures();
this._featureSnapshot = featureSnapshot;
// Merge feature properties into element properties
Object.assign(this.elementProperties, featureSnapshot.properties);
}
// ... rest of finalize ...
}Add feature instantiation to constructor:
constructor() {
super();
// ... existing initialization ...
// NEW: Initialize feature manager if features are present
if ((this.constructor as any)._featureSnapshot) {
this._featureManager = new FeatureManager(this);
}
}Extend lifecycle methods:
connectedCallback() {
this._featureManager?.processLifecycle('beforeConnectedCallback');
// ... existing connectedCallback ...
this._featureManager?.processLifecycle('afterConnectedCallback');
}
// Similar for disconnectedCallback, updated, firstUpdated, etc.Add Feature as a core export (similar to ReactiveController):
// Part of lit/reactive-element
export class Feature<TConfig = any> implements ReactiveController {
host: ReactiveElement;
config: TConfig;
static properties: PropertyDeclarations = {};
constructor(host: ReactiveElement, config: TConfig) {
this.host = host;
this.config = config;
host.addController(this);
this._initializeProperties();
}
// Property synchronization logic
// Lifecycle methods
}The existing @property() decorator would detect context:
export function property(options?: PropertyDeclaration) {
return (protoOrTarget: any, nameOrContext: string | ClassFieldDecoratorContext) => {
// Determine if we're decorating a Feature or ReactiveElement
const isFeature = protoOrTarget instanceof Feature ||
protoOrTarget.prototype instanceof Feature;
if (isFeature) {
// Store in feature metadata
return createFeatureProperty(options);
} else {
// Existing element property logic
return createElementProperty(options);
}
};
}Add @provide and @configure as core decorators:
// Part of lit/decorators
export { provide, configure } from './feature-decorators.js';All changes are additive only:
- Existing components without features work exactly as before
- Features are opt-in via
@provideorstatic provide - No breaking changes to existing APIs
- Feature system is isolated and only activates when used
- Lazy Resolution: Feature snapshots are resolved once and cached
- Conditional Overhead: Feature manager only instantiates if features are present
- Property Optimization: Property merging happens at finalize time (once per class)
- Lifecycle Delegation: Minimal overhead in lifecycle methods (Map iteration)
The @property decorator integration is key to making features feel native to Lit.
Features use a separate @property decorator from './decorators/feature-property.js':
export class ThemeFeature extends LitFeature {
@property({ type: String, reflect: true })
theme: 'light' | 'dark' | 'auto' = 'light';
}Single unified @property decorator that intelligently detects context:
import { property } from 'lit/decorators.js';
// Works on LitElement
export class MyElement extends LitElement {
@property({ type: String })
name: string = 'default';
}
// Works on Feature (automatically!)
export class MyFeature extends Feature {
@property({ type: String, reflect: true })
status: string = 'active';
}The decorator inspects the class hierarchy to determine behavior:
export function property(options?: PropertyDeclaration) {
return (protoOrTarget: any, nameOrContext: PropertyKey | DecoratorContext) => {
const target = getTargetFromContext(protoOrTarget, nameOrContext);
const ctor = target.constructor;
// Check if this is a Feature class
const isFeatureClass = isFeature(ctor);
if (isFeatureClass) {
// Feature property behavior:
// 1. Store in feature's static properties
// 2. Register in feature metadata for host merging
// 3. Will be merged into host during finalize()
return handleFeatureProperty(ctor, nameOrContext, options);
} else {
// Standard ReactiveElement property behavior
return handleElementProperty(target, nameOrContext, options);
}
};
}
function isFeature(ctor: any): boolean {
// Walk prototype chain looking for Feature base class
let proto = ctor;
while (proto && proto !== Object) {
if (proto.name === 'Feature') return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}- Single Import: Users import from
'lit/decorators.js'for both elements and features - Consistent API: Same decorator syntax regardless of context
- Type Safety: TypeScript types work identically
- Zero Confusion: No need to remember which decorator to use where
For codebases using the POC:
// Before (POC):
import { property } from './root/decorators/feature-property.js';
// After (integrated):
import { property } from 'lit/decorators.js';Question: Should we use provide/configure or explore alternatives?
Alternatives considered:
@feature()/@featureConfig()- More verbose but clearer@use()/@setup()- Shorter but less semantic@mixin()/@mixinConfig()- Familiar but potentially confusing with actual mixins
Recommendation: Keep @provide and @configure - they clearly express intent and avoid confusion with other patterns.
Question: Should we support both patterns or standardize on decorators?
Current POC: Supports both
// Decorators (modern)
@provide('Feature', definition)
// Static getters (traditional)
static get provide() { return { Feature: definition }; }Considerations:
- Decorators are more ergonomic for single features
- Static getters are better for defining many features at once
- Some users prefer avoiding decorators (stage 3 concern)
- Both patterns can coexist with minimal complexity
Recommendation: Support both patterns in core, but document decorators as the primary/preferred approach.
Question: Should features be stored as this.FeatureName or in a namespace like this.features.FeatureName?
Current POC: Direct properties (this.Theme, this.Dismiss)
Alternatives:
// Option A: Direct (current)
this.Status.getStatusIcon();
// Option B: Namespaced
this.features.Theme.getResolvedTheme();
// Option C: Private with accessors
this._features.get('Theme').getResolvedTheme();Trade-offs:
- Direct: Cleaner API, potential naming conflicts
- Namespaced: Clearer separation, no conflicts, more verbose
- Private: Encapsulation, requires explicit accessors
Recommendation: Start with direct properties (current approach) but reserve property names starting with capital letters for features only. This convention makes feature instances easily distinguishable from component properties.
Question: How can we improve the DX for feature property types?
Current approach:
@provide('Theme', { class: ThemeFeature })
class MyElement extends LitCore {
declare Theme: ThemeFeature; // Manual declaration
declare theme: 'light' | 'dark' | 'auto';
}Desired: Automatic type inference or code generation
Challenges:
- TypeScript decorators don't modify class types
- No way to automatically add types from feature classes
Potential Solutions:
-
Vue-style defineComponent helper:
export const MyElement = defineComponent(LitCore) .withFeature('Theme', ThemeFeature) .build(); // Type inference from builder pattern
-
TypeScript transformer plugin (build step)
-
JSDoc with @typedef for JavaScript users
-
Accept manual
declarestatements as acceptable DX (current approach)
Recommendation: Start with manual declare statements and explore builder pattern or TS transformers in future iterations.
Question: Should we expose more granular lifecycle hooks?
Current hooks:
- beforeConnectedCallback / connectedCallback / afterConnectedCallback
- beforeDisconnectedCallback / disconnectedCallback / afterDisconnectedCallback
- beforeFirstUpdated / firstUpdated / afterFirstUpdated
- beforeUpdated / updated / afterUpdated
- beforeAttributeChangedCallback / afterAttributeChangedCallbackConsiderations:
- More hooks = more power but more complexity
- Most features only need a few lifecycle points
- Performance impact of calling many no-op methods
Recommendation: Keep current granularity for now. The before/after hooks provide flexibility for feature coordination without being overwhelming. Consider adding more hooks if clear use cases emerge.
Question: Should we support explicit feature dependencies?
Current approach: Features discover each other at runtime
const otherFeature = this.host.OtherFeature;
if (otherFeature) {
otherFeature.doSomething();
}Alternative: Explicit dependency declaration
@provide('Dismiss', {
class: SwipeDismissFeature,
requires: ['Theme'] // ← Enforce at resolution time
})Trade-offs:
- Explicit: Fails fast, clearer contracts, more rigid
- Optional: More flexible, requires null checks, potential runtime errors
Recommendation: Start with optional discovery (current approach). Add requires and optional dependency declarations in a future iteration if ecosystem adoption shows clear patterns.
Question: Should features be disable-able after instantiation?
Current: Features can only be disabled at configuration time
@configure('Pulse', 'disable')Potential: Runtime disabling
this.featureManager.disableFeature('Pulse');
this.featureManager.enableFeature('Pulse');Use case: Toggle features based on runtime conditions (permissions, feature flags, etc.)
Recommendation: Keep static configuration for MVP. Runtime toggling adds significant complexity (property cleanup, lifecycle management) without clear must-have use cases yet.
Question: Should features be able to compose other features?
Scenario:
// Can a feature itself use the @provide/@configure system?
class ComplexFeature extends Feature {
@provide('SubFeature', { class: SubFeature })
static provide = { ... };
}Considerations:
- Powerful for complex behaviors
- Adds conceptual and implementation complexity
- Current pattern encourages flat composition
Recommendation: Defer this. Current pattern of flat feature composition is simpler and covers most use cases. Nested features can be explored if adoption reveals a strong need.
Question: What happens when two features define the same property name?
Current behavior: Last feature wins (merge order dependent)
Alternatives:
- Throw error at finalize time
- Namespace properties automatically (e.g.,
Theme_primary,Animation_duration) - Require explicit resolution via
@configure
Recommendation: Throw error at finalize time (fail fast). Property collisions indicate poor feature design or naming. Developers should resolve by:
- Renaming properties in custom feature subclass
- Using
properties: { conflictName: 'disable' }in@configure - Choosing better feature names
Question: How do features relate to existing ReactiveController usage?
Current: Features ARE reactive controllers (implement the interface)
Key point: Features and controllers coexist:
class MyElement extends LitElement {
@provide('Theme', { class: ThemeFeature })
static provide = { ... };
// Also use traditional controllers
private _mouseController = new MouseController(this);
// Theme feature available as this.Theme
// Mouse controller used traditionally
}Recommendation: Position features as "reactive controllers with superpowers" (inheritance + property merging). They complement rather than replace existing controller patterns. Document migration path from controllers to features.
- Implement core architecture (LitCore, LitFeature, FeatureManager)
- Create example features (Ripple, Pulse, Theme, Dismiss chain)
- Demonstrate inheritance chain
- Support both decorators and static getters
- Create working demo components
- Write comprehensive test suite
- Document all APIs
- Create this proposal
- Share POC with Lit team for initial feedback
- Present at Lit community meeting
- Gather feedback on API design and use cases
- Iterate on decorator/property integration strategy
- Benchmark performance vs. mixin patterns
- Address outstanding questions based on feedback
- Create branch of Lit core with feature system integrated
- Implement unified
@propertydecorator - Add
Featurebase class toreactive-element - Port feature resolution into
ReactiveElement.finalize() - Create comprehensive test suite for core integration
- Update Lit's TypeScript definitions
- Write migration guide from POC to core
- Write tutorials for common feature patterns
- Document feature lifecycle in detail
- Create best practices guide
- Build reference features for common UI patterns:
- Focus management
- Keyboard navigation
- ARIA roles/attributes
- Animation coordination
- Form validation
- Responsive behavior
- Port existing Lit patterns to features (show equivalents)
- Release as experimental feature behind flag
- Create migration tools for existing codebases
- Gather real-world usage feedback
- Iterate on DX and performance
- Address TypeScript typing limitations
- Refine based on ecosystem patterns
- Finalize API based on preview feedback
- Complete documentation
- Release as stable feature in Lit
- Update lit.dev with feature system docs
- Create Lit labs packages for common features
- Present at web component conferences
Developer Experience:
- Reduced mixin depth in component libraries
- Improved code reusability across component hierarchies
- Clearer separation of concerns in components
- Faster onboarding for new team members
Technical:
- No performance regression vs. mixin patterns
- Minimal bundle size increase
- Full TypeScript support
- Compatible with existing Lit decorators and patterns
Adoption:
- Major design systems adopt feature patterns
- Community creates reusable feature packages
- Positive feedback from Lit ecosystem
Composable Feature-Centric Controllers extend Lit's already excellent composition model with inheritance-aware, declarative feature management. This pattern:
- Simplifies component inheritance by avoiding deep mixin chains
- Enables fine-grained control over inherited behaviors
- Maintains Lit's reactive property system and lifecycle model
- Provides clear mental model: features are "controllers with superpowers"
- Integrates naturally into Lit's existing architecture
The POC demonstrates that this pattern is technically feasible, ergonomic, and powerful. With community feedback and refinement, it has the potential to become a core part of how developers build composable web components with Lit.
POC Repository: LitFeature
Primary Contact: [Add contact info]
Status: Proposal / Proof of Concept
Date: February 19, 2026