A Modern Twist on a Retro Classic
Modern browsers have evolved into powerful platforms capable of running complex applications that would have seemed impossible only a couple of decades ago. Native features like web components are now widely adopted by major companies, from GitHub to Apple Music. This opens up an interesting opportunity: using today's technology to recreate the distinctive interfaces of yesteryear, complete with their unique quirks and visual charm.
We'll build a draggable "broken window" effect (inspired by old operating systems) using web components and the Lit library, which streamlines the native component APIs. This article covers creating Lit components, customizing behavior, reusing functionality, and handling advanced data flow through custom events.
To follow along, you'll need a basic grasp of HTML, CSS, and JavaScript. No framework experience is necessary. You can code along in the browser using the provided StackBlitz project, or clone the repository and follow the setup instructions in its README.md.
The starter project is simple. The index.html file imports CSS and a JavaScript file and contains a custom a2k-window element. This element doesn't exist yet, so the browser renders its inner HTML as a fallback. Several .js files contain boilerplate code and necessary imports that we'll build upon.
For visual flair, the project includes a retro font inspired by the classic MS-2000 aesthetic.
Creating and Styling the Component
First, we'll build the visible window structure. Our journey starts in the a2k-window.js file. We define a class that extends Lit's LitElement base class, which provides the foundation for reactive state and properties. We also need to implement a render function.
class A2kWindow extends LitElement {
render() {
return html`
<div id="window">
<slot></slot>
</div>
`;
}
}
Two things are important here:
- An element ID can be specified and is encapsulated within the component. This means other components can use the same ID without conflict.
- The
slotelement presents a placeholder for custom markup passed from a parent, similar to React'schildrenprop.
To make this class usable in HTML, we register it as a custom element that associates the definition with the a2k-window tag name. Add the following line beneath the component class:
customElements.define("a2k-window", A2kWindow);
The newly rendered component will appear, but it will be completely unstyled. We'll add more HTML and CSS to give it shape:
class A2kWindow extends LitElement {
static styles = css`
:host {
font-family: var(--font-primary);
}
#window {
width: min(80ch, 100%);
}
#panel {
border: var(--border-width) solid var(--color-gray-400);
box-shadow: 2px 2px var(--color-black);
background-color: var(--color-gray-500);
}
#draggable {
background: linear-gradient(
90deg,
var(--color-blue-100) 0%,
var(--color-blue-700) 100%
);
user-select: none;
}
#draggable p {
font-weight: bold;
margin: 0;
color: white;
padding: 2px 8px;
}
[data-dragging="idle"] {
cursor: grab;
}
[data-dragging="dragging"] {
cursor: grabbing;
}
`;
render() {
return html`
<div id="window">
<div id="panel">
<slot></slot>
</div>
</div>
`;
}
}
Note the following from this code:
- We define component-scoped styles via the
static stylesproperty. The Shadow DOM's encapsulation means our component won't be affected by external global styles. However, we can still use the CSS variables added in our mainstyles.css. - We've also added styles for DOM elements that we will create later on.
Making the Component Customizable and Reactive
Next, we'll create the window's heading. A core feature of web components is using element properties. To avoid hardcoding its text, we make the heading an input property using Lit's reactive system.
This requires three steps:
- Define the reactive property.
- Assign it a default value.
- Render that value to the DOM.
First, we'll specify the static properties object on the class. The heading property uses Lit's default options, which handle string conversion automatically, so we can leave its configuration as an empty object:
class A2kWindow extends LitElement {
static styles = css`...`;
static properties = {
heading: {},
};
render() {...}
}
Next, we assign the default value within the component's constructor method, making sure to call super():
class A2kWindow extends LitElement {
static styles = css`...`;
static properties = {...};
constructor() {
super();
this.heading = "Building Retro Web Components with Lit";
}
render() {...}
}
Finally, we add the necessary markup to render the value to the DOM:
class A2kWindow extends LitElement {
static styles = css`...`;
static properties = {...};
constructor() {...}
render() {
return html`
<div id="window">
<div id="panel">
<div id="draggable">
<p>${this.heading}</p>
</div>
<slot></slot>
</div>
</div>
`;
}
}
The component will now display the heading text you passed to it. It is easy to build UI from 1998 with the modern primitives of 2022 — and these are only the foundational steps. The real potential of Lit’s intermediate features lies ahead, particularly in creating a drag function that can be reused across our custom components.
Implementing Drag Behavior
The drag mechanics rely on two key Lit concepts: directives and controllers. Directives let us break out of Lit's normal template rendering flow to extend functionality. The styleMap directive, for instance, converts a JavaScript object into inline styles — ideal for managing the dynamic left and top values that position our window. Controllers, on the other hand, are classes that encapsulate state and logic while hooking into a host component's lifecycle.
export class DragController {
x = 0;
y = 0;
state = "idle"
styles = {...}
constructor(host, options) {
this.host = host;
this.host.addController(this);
}
hostDisconnected() {...}
onDragStart = (pointer, ev) => {...};
onDrag = (_, pointers) => {...};
}
In this pattern, the controller receives a reference to its host element (our a2k-window component) and can leverage lifecycle hooks like hostConnected and hostDisconnected for setup and cleanup. The controller also owns public properties and methods the host can call, making it a clean way to manage the drag state and positioning without cluttering the component's template code.
Connecting the Controller
The controller's initialization accepts an options object containing callbacks to access two critical elements: the container and the draggable element itself.
export class DragController {
x = 0;
y = 0;
state = "idle";
styles = {
position: "absolute",
top: "0px",
left: "0px",
};
constructor(host, options) {
const {
getContainerEl = () => null,
getDraggableEl = () => Promise.resolve(null),
} = options;
this.host = host;
this.host.addController(this);
this.getContainerEl = getContainerEl;
getDraggableEl().then((el) => {
if (!el) return;
this.draggableEl = el;
this.init();
});
}
init() {...}
hostDisconnected() {...}
onDragStart = (pointer) => {...};
onDrag = (_, pointers) => {...};
}
Note that getDraggableEl is a promise. This ensures we only attach event listeners once the draggable element has actually rendered. When the promise resolves, we store the element reference and initialize the drag listeners. This asynchronous handshake is necessary because, as we'll see, the controller needs access to elements inside the Shadow DOM after a render cycle completes.
For event tracking, we'll use the PointerTracker library, which smooths over the cross-browser complexities of handling pointer input. It takes the draggable element and an object of handlers:
start: fires on pointer down.move: fires during the drag.end: fires on pointer release.
Each handler either updates a state property or invokes a callback. Since the state is meant to be reflected as an attribute on the host, triggering a re-render is handled via this.host.requestUpdate.
Calculating the New Position
The drag start logic simply records the pointer's starting coordinates relative to the element it's about to move.
onDragStart = (pointer, ev) => {
this.cursorPositionX = Math.floor(pointer.pageX);
this.cursorPositionY = Math.floor(pointer.pageY);
};
When the move event fires, PointerTracker hands us a list of active pointers. Supporting single-window dragging only, we take the first entry and pass it to the position calculation logic.
calculateWindowPosition(pointer) {
const el = this.draggableEl;
const containerEl = this.getContainerEl();
if (!el || !containerEl) return;
const oldX = this.x;
const oldY = this.y;
//JavaScript’s floats can be weird, so we’re flooring these to integers.
const parsedTop = Math.floor(pointer.pageX);
const parsedLeft = Math.floor(pointer.pageY);
//JavaScript’s floats can be weird, so we’re flooring these to integers.
const cursorPositionX = Math.floor(pointer.pageX);
const cursorPositionY = Math.floor(pointer.pageY);
const hasCursorMoved =
cursorPositionX !== this.cursorPositionX ||
cursorPositionY !== this.cursorPositionY;
// We only need to calculate the window position if the cursor position has changed.
if (hasCursorMoved) {
const { bottom, height } = el.getBoundingClientRect();
const { right, width } = containerEl.getBoundingClientRect();
// The difference between the cursor’s previous position and its current position.
const xDelta = cursorPositionX - this.cursorPositionX;
const yDelta = cursorPositionY - this.cursorPositionY;
// The happy path - if the element doesn’t attempt to go beyond the browser’s boundaries.
this.x = oldX + xDelta;
this.y = oldY + yDelta;
const outOfBoundsTop = this.y < 0;
const outOfBoundsLeft = this.x < 0;
const outOfBoundsBottom = bottom + yDelta > window.innerHeight;
const outOfBoundsRight = right + xDelta >= window.innerWidth;
const isOutOfBounds =
outOfBoundsBottom ||
outOfBoundsLeft ||
outOfBoundsRight ||
outOfBoundsTop;
// Set the cursor positions for the next time this function is invoked.
this.cursorPositionX = cursorPositionX;
this.cursorPositionY = cursorPositionY;
// Otherwise, we force the window to remain within the browser window.
if (outOfBoundsTop) {
this.y = 0;
} else if (outOfBoundsLeft) {
this.x = 0;
} else if (outOfBoundsBottom) {
this.y = window.innerHeight - height;
} else if (outOfBoundsRight) {
this.x = Math.floor(window.innerWidth - width);
}
this.updateElPosition();
// We trigger a lifecycle update.
this.host.requestUpdate();
}
}
updateElPosition(x, y) {
this.styles.transform = `translate(${this.x}px, ${this.y}px)`;
}
The core update function performs a few distinct checks in sequence:
- Guard clauses: It first verifies both
draggableElandcontainerElexist. - Delta calculation: It determines if the cursor has actually moved by comparing the current position with the position recorded at drag start. If there's no movement, it exits early.
- Position update: It calculates the new
xandyvalues based on the pointer's offset. - Boundary enforcement: It checks whether the element's new position would push it outside the container's boundaries. If so, it clamps the
xoryvalue to keep the window within its parent.
Once the final coordinates are determined, the function updates this.styles and triggers the host's update cycle. The styleMap directive is what takes these object values and applies them as the element's inline CSS, causing the visual move.
Finally, to avoid memory leaks and ghost drag states, we need to clean up the pointer tracker if the component is disconnected mid-drag.
hostDisconnected() {
if (this.pointerTracker) {
this.pointerTracker.stop();
}
}
Wiring Up the Host Component
Back in the a2k-window component file, three changes are needed to integrate the controller:
- Instantiate the controller.
- Bind the controller's style updates to the template via
styleMap. - Expose the drag state for styling or internationalization purposes.
Because the controller needs to find elements inside the component's shadow tree, we query using this.shadowRoot.querySelector(selector), which works across the Shadow DOM boundary. We also must ensure we wait for the component's first paint by awaiting this.updateComplete before attempting to resolve the draggable element's promise. This guarantees the element exists in the DOM tree to receive the event listeners. Once that's all set, the component is ready to be freely dragged within its container.
Wiring Up a Drag Event with CustomEvent
The drag controller is reusable on its own, but to build effects like the broken window visual, the controller needs to communicate with the outside. The cleanest way to do this in a component-based architecture is by dispatching native DOM events rather than threading callbacks through every layer of the component tree.
Lit supports inline event handlers in templates directly, which works well when the element and its handler are colocated:
handleClick() {
console.log("Clicked");
}
render() {
html`<button @click="${this.handleClick}">Click me!</button>`
}
That approach becomes unwieldy when the triggering element is deeply nested. The better pattern is to dispatch a CustomEvent from the controller and let any ancestor listen for it. A minimal example looks like this:
// Event Listener
class SpecialListener extends LitElement {
constructor() {
super()
this.specialLevel = '';
this.addEventListener('special-click', this.handleSpecialClick)
}
handleSpecialClick(e) {
this.specialLevel = e.detail.specialLevel;
}
render() {
html`<div>
<p>${this.specialLevel}</p>
<special-button>
</div>`
}
}
// Event Dispatcher
class SpecialButton extends LitElement {
handleClick() {
const event = new CustomEvent("special-click", {
bubbles: true,
composed: true,
detail: {
specialLevel: 'high',
},
});
this.dispatchEvent(event);
}
render() {
html`<button @click="${this.handleClick}">Click me!</button>`
}
}
In the dispatcher, the handleClick method does the following:
- Creates an event with
new CustomEvent('special-click', {...}). - Sets
bubbles: trueso the event travels up the DOM tree. - Sets
composed: trueso it crosses shadow DOM boundaries. - Passes data via the
detailoption, then callsthis.dispatchEvent(event).
Now the controller should emit a window-drag event from inside the onDrag callback. The event's detail object should include a reference to the dragged element's container. Since the controller keeps a reference to that element on its instance, dispatching from there is straightforward. A complete solution looks like:
onDrag = (_, pointers) => {
this.calculateWindowPosition(pointers[0]);
const event = new CustomEvent("window-drag", {
bubbles: true,
composed: true,
detail: {
containerEl: this.getContainerEl(),
},
});
this.draggableEl.dispatchEvent(event);
};
Before wiring the final effect, add a listener in script.js to verify the event fires correctly:
function onWindowDrag() {
console.log('dragging');
}
window.addEventListener('window-drag', onWindowDrag);
With that in place, drag the element and confirm the log output in the browser console.
Building the Broken Window Component
Next, create a new a2k-broken-window element. Its markup uses nested divs, each with its own responsibility:
- The outermost
divhandles positioning. - The middle
divmanages the visual appearance. - The innermost
divdefines width and height.
Here is the full implementation:
export class BrokenWindow extends LitElement {
static properties = {
height: {},
width: {},
top: {},
left: {},
};
static styles = css`
#outer-container {
position: absolute;
display: flex;
}
#middle-container {
border: var(--border-width) solid var(--color-gray-400);
box-shadow: 2px 2px var(--color-black);
background-color: var(--color-gray-500);
}
`;
render() {
return html`
<div
style=${styleMap({
transform: `translate(${this.left}px, ${this.top}px)`,
})}
id="outer-container"
>
<div id="middle-container">
<div
style=${styleMap({
width: `${this.width}px`,
height: `${this.height}px`,
})}
></div>
</div>
</div>
`;
}
}
window.customElements.define("a2k-broken-window", BrokenWindow);
Test it by temporarily dropping this element into index.html:
<a2k-broken-window top="100" left="100" width="100" height="100"></a2k-broken-window>
If the broken window renders correctly, it will appear as expected in the browser.
Both the standard window and the broken window share several styles. A worthwhile refactor is to extract that common markup and CSS into a separate a2k-panel component. This composition technique is documented in the Lit component composition guide and keeps each window component focused on its own behavior.
Spawning Broken Windows On Drag
The final step ties the event dispatch to DOM insertion. The script.js listener needs to do five things when it receives a window-drag event:
- Read the
containerElfrom the event'sdetail. - Call
containerEl.getBoundingClientRect()to get the CSS position and dimensions. - Create a new
a2k-broken-windowelement. - Set the element's
top,left,width, andheightproperties. - Insert it into the DOM right before the original window.
Both of the first two steps are handled together here:
function onWindowDrag(e) {
const { containerEl } = e.detail;
const { width, top, left, height } = containerEl.getBoundingClientRect();
}
window.addEventListener("window-drag", onWindowDrag);
With the bounding rect available, create the broken window imperatively and apply its styles:
function onWindowDrag(e) {
const { containerEl } = e.detail;
const { width, top, left, height } = containerEl.getBoundingClientRect();
const newEl = document.createElement("a2k-broken-window");
newEl.setAttribute("width", width);
newEl.setAttribute("top", top);
newEl.setAttribute("left", left);
newEl.setAttribute("height", height);
}
Adding it to the DOM requires care about placement. Appending to the body would cover newer windows, and prepending would stack it above existing ones. The insertBefore API provides the precise control needed to place the broken window directly in front of its source window:
containerEl.insertAdjacentElement("beforebegin", newEl);
Here is the entire finished script:
function onWindowDrag(e) {
const { containerEl } = e.detail;
const { width, top, left, height } = containerEl.getBoundingClientRect();
const newEl = document.createElement("a2k-broken-window");
newEl.setAttribute("width", width);
newEl.setAttribute("top", top);
newEl.setAttribute("left", left);
newEl.setAttribute("height", height);
containerEl.insertAdjacentElement("beforebegin", newEl);
}
window.addEventListener("window-drag", onWindowDrag);
Return to the browser and drag a window. The broken window effect should now appear each time, with the fractured remnants persisting in place as the original window moves away. If something fails, inspect the console for errors and compare each snippet against the full implementation.
That event-driven setup isn't just for one effect, though. Because the drag controller dispatches a generic event, any consumer—whether a window, an icon, or something else entirely—can hook into that signal and run its own logic without modifying the component. The retro window UI and this broken-window effect are part of the A2k component library, available on GitHub.



