Skip to main content
JoshuaBriley.
Back to blog
Design Systems 42 min read

Staff-level UI engineering

A practical, plain-language guide to Staff and Principal UI engineering: design systems, Web Components, UI platforms, accessibility, packaging, governance, and career growth.

There is a version of front-end seniority that tops out at “can build the hardest dropdown.” This guide is about the other version, where the job stops being the interface and becomes the system that produces interfaces, for every team that ships one.

This is one career niche, not a general engineering ladder: browser technology, component architecture, accessibility, package distribution, developer experience, and the boundary where design meets code. Call it Staff-level UI engineering for design systems and shared UI platforms. A general-purpose Staff software engineer and a Staff UI platform engineer share a lot of concepts and almost none of the daily surface area.

Anything here that’s a matter of record is footnoted to a primary source. Anything that’s judgment is mine, stated plainly. One caveat: Senior, Staff, and Principal aren’t standardized titles. Monzo says so directly, and GitLab’s frameworks differ from everyone else’s.123 Read every level description below as one company’s answer.

What the job actually is

Published career frameworks show what actually changes at each level. GitLab separates Senior, Staff, and Principal individual-contributor levels, with broader technical and organizational scope at each step.423

Its Staff framework describes Staff engineers as technical leaders for a domain: owning tradeoffs and architecture, removing blockers, decomposing complex requirements, and blending technical, product, and design concerns for longer-term impact.2 The Staff Frontend framework adds delivering despite unclear requirements, leading large architectural changes, improving standards and tooling, mentoring, and influencing frontend quality beyond your own tasks.5 Principal scope is broader still: organizational technical leadership, cross-team architecture, tradeoffs that affect the business, unblocking multiple teams at once.36

Monzo’s public write-up is a useful counterweight: one company being specific rather than a framework being general. It describes Staff engineers as individual contributors with no direct reports, working on highly ambiguous problems across squads, mentoring, and creating multiplicative impact. It also admits what most ladders leave out: Staff engineers often write less code, because the scope widened underneath them.1

Strip the frameworks down and the ladder is about who your work is for.

LevelTrusted toWhere the leverage sits
SeniorSolve hard problemsThe problem in front of you
StaffImprove how a group of engineers solves hard problemsA domain, and the teams inside it
PrincipalImprove how an organization solves classes of hard problemsArchitecture, standards, and strategy across teams

It’s a simplification, but it holds up well in interviews and career planning.

Scope is the change, not raw skill

Take a concrete case. A button component has an accessibility defect.

The senior response

Diagnose the defect. Fix the component. Add a test. Document the behavior. Ship the release.

This is correct, necessary work, and none of what follows replaces it. Someone still has to fix the button.

The staff response

All of that, plus a wider set of questions:

  • Why did our process let this ship?
  • Do other components share the same defect?
  • Are the automated tests checking the right thing?
  • Does the component API make accessible behavior easy or hard?
  • Can the default be made safe, so product teams need no accessibility expertise to use it correctly?
  • How do existing consumers receive the fix, and is it a patch or a break?
  • How will we know which applications are still on the bad version?
  • Does the design library teach the same behavior as the code does?

The Staff engineer usually still writes the fix. The difference is that the engineer also repairs whatever produced the defect, so the next one has a harder time getting out.

Make correct UI behavior easier for every product team, not only for the application you happen to be working on today.

None of this requires direct reports

Staff and Principal can be individual-contributor levels. GitLab publishes both on its IC career matrix, and Monzo’s example explicitly describes the role as having no direct reports.41 Leadership travels through artifacts instead: architecture proposals, technical specifications, code reviews, standards, prototypes, migration plans, and the unglamorous work of helping other engineers reach good decisions without you in the room.

If you like building things and do not want to manage people, Staff or Principal design-system work is one of the few paths that keeps getting more interesting rather than more administrative.

From component library to UI platform

These three terms get used interchangeably, and the difference between them is most of what this guide is about.

Component library

How do developers reuse this UI code?

Reusable implementation code. A button, an input, a dialog, a date picker. The output is code other people import.

Design system

How should this organization design and build interfaces?

The library plus the shared decisions around it: patterns, tokens, accessibility guidance, documentation. The output is agreement, and code that encodes it. This is a working definition for this guide, not a universal one.

UI platform

Can many teams depend on this safely, for years?

What a design system becomes once enough teams depend on it that it needs a deliberate public API, a release process, a compatibility strategy, a support model, and a migration path. The output is infrastructure, and infrastructure has consumers.

The third row is where Staff-level UI work becomes most valuable, because it is the layer where many teams share the consequences of one architectural decision.

A platform is a pipeline with a fan-out in the middle, and every stage of it is somebody’s job:

Design decisions

What the organization has agreed a button, a spacing step, or an error state is. Usually held in design tooling, occasionally only in someone’s head.

Design tokens

Those decisions given names and made portable, so they can travel between tools and platforms without being retyped by hand.

Shared components

The tokens rendered as behavior. Web Components put this layer on the browser rather than on a framework, which is the whole reason to choose them.

Consumers

A React adapter, plain HTML, whatever else is in the estate. Each one is a translation layer with its own ergonomics and its own maintenance cost.

Products

The applications people actually use. Everything upstream is only worth what reaches this row.

Around that pipeline sits everything that keeps it alive: accessibility, documentation, testing, versioning, distribution, governance, adoption, support. The component is one piece, and rarely the piece that fails.

The public API is the product

A product team using a shared component does not care whether its internals are Lit, Stencil, or hand-written Custom Elements. It cares about the contract. For a Web Component, that contract is bigger than most people first assume:

  • Tag name
  • Attributes
  • Properties
  • Methods
  • Events
  • Event payloads
  • Slots
  • CSS custom properties
  • CSS parts
  • DOM semantics
  • Keyboard behavior
  • Focus behavior
  • Accessible name and role

Treat that whole surface the way you would treat an API you sell to customers. Changing an internal implementation is cheap. Changing a contract that hundreds of applications depend on is not, and the cost lands on people who did not choose it. This is the single principle that governs everything in the migration section later.

The browser platform beneath the frameworks

The HTML Standard defines Custom Elements as a way for authors to build their own DOM elements with custom behavior. The DOM Standard defines the event dispatch, composition, retargeting, and Shadow DOM concepts those elements rely on.78 Stencil and Lit are higher-level APIs over exactly those capabilities.910

That’s the reason to learn the layer underneath: a Staff-level Web Component engineer should be able to reason about a component after every framework name is deleted from the conversation. Frameworks are the top row, and the top row is the one that gets replaced.

Lit / Stencil / FAST

Ergonomics. Reactive updates, templating, less boilerplate. The replaceable layer.

Custom Elements

The registry binding a tag name to a class, plus the lifecycle callbacks around connection and attribute change.

Shadow DOM

Encapsulation. A private tree, and the styling boundary drawn around it.

DOM events

How a component speaks outward, and whether that speech crosses the shadow boundary.

HTML semantics

Role, name, and state, supplied free by the browser if you picked the right element.

CSS

Custom properties and parts are the only styling hooks a consumer gets. You choose them on purpose or they get chosen for you.

Accessibility APIs

What assistive technology actually reads. The bottom of every stack, and the layer no framework can paper over.

Custom Elements, plainly

A native button is created with <button>Save</button>. A Custom Element lets you define a new element name and associate it with a JavaScript class through the browser’s registry.7

class UiStatusMessage extends HTMLElement {
  connectedCallback() {
    if (!this.hasAttribute('role')) {
      this.setAttribute('role', 'status')
    }

    this.textContent ||= 'Ready'
  }
}

customElements.define('ui-status-message', UiStatusMessage)

That element now works anywhere HTML works, including inside templating languages that have never heard of it.

The lifecycle detail that catches people out is that an element can be attached, removed, and attached again. So this leaks:

connectedCallback() {
  window.addEventListener("resize", this.onResize);
}

Every teardown needs to pair with its setup, the same pattern Lit recommends whenever a component listens to something it doesn’t own, like window or document.11

connectedCallback() {
  window.addEventListener("resize", this.onResize);
}

disconnectedCallback() {
  window.removeEventListener("resize", this.onResize);
}

Attributes and properties are different things

They look interchangeable in a framework and they are not.

Attributes

Serialized text sitting in markup. They survive in static HTML, show up in the inspector, and can be targeted by CSS selectors.

Because the value is a string, everything else has to be parsed out of one.

Good for: simple scalar configuration. A variant, a size, a boolean state.

Properties

Live JavaScript values on the element object. They hold anything the language holds: objects, arrays, functions, class instances.

They leave no trace in markup, which means no static HTML and no CSS selector can reach them.

Good for: structured data. A list of menu items, a formatter, a fetched record.

In practice that means the same component is configured two ways, and the split is not arbitrary:

<ui-button variant="primary"></ui-button>
userMenu.items = [
  { id: 1, label: 'Profile' },
  { id: 2, label: 'Sign out' },
]

Lit provides reactive properties that trigger rendering and can optionally reflect back to attributes.12 Stencil exposes public data through @Prop(), documented as attributes and properties on the element.13 Both make reflection a one-word change, which is why it gets overused. Reflect when it has a job: declarative HTML configuration, a CSS selector that needs the state, debugging visibility, or semantics that belong in markup. Reflecting a large object because the decorator allowed it just puts noise in the DOM.

Shadow DOM, and the hooks you deliberately leave open

Shadow DOM gives a host element its own encapsulated tree. The DOM Standard distinguishes the document tree from shadow trees and defines how both participate in event dispatch.8 Lit uses it by default.14

The component gets a small private room, and <slot> marks the doors where consumer content is allowed in:

<!-- what the component holds internally -->
#shadow-root
<article>
  <slot name="name"></slot>
</article>

<!-- what the consumer writes -->
<ui-user-card>
  <strong slot="name">Joshua</strong>
</ui-user-card>

Encapsulation solves one problem and creates another: consumers still need to restyle things, and if you give them no legitimate way to do it, they’ll find an illegitimate one. Two platform mechanisms exist. CSS custom properties pass values in. CSS Shadow Parts, via the part attribute and ::part() selector, expose selected internal elements for outside styling.15

/* Component: declare the hook */
:host {
  --ui-button-background: ButtonFace;
}
button {
  background: var(--ui-button-background);
}

/* Consumer: use it */
ui-button {
  --ui-button-background: rebeccapurple;
}
<!-- Component: expose one internal element by name -->
<button part="control">
  <slot></slot>
</button>
/* Consumer: reach it */
ui-button::part(control) {
  border-radius: 999px;
}

Both of these are API design decisions wearing CSS clothing. Expose too little and consumers hack around the system, usually with !important and a selector that breaks on your next release. Expose every internal element and you have made your private implementation public, permanently. The useful question is not “can they style this?” but “am I willing to keep this element, with this name, in this position, for the next three years?”

Server rendering touches this same boundary. The HTML Standard supports declarative Shadow DOM through shadowrootmode, so a shadow root can be expressed in markup instead of only constructed by client-side JavaScript.16

<ui-card>
  <template shadowrootmode="open">
    <article>
      <slot></slot>
    </article>
  </template>
  Hello
</ui-card>

Not every component needs to be server-rendered. But rendering strategy, hydration cost, search requirements, and first-paint behavior stop being one product’s concern the moment a design system is shared, because the platform’s answer becomes every product’s answer.

Events are the outbound half of the contract

Events are how a component reports that something happened. The DOM Standard defines bubbles, cancelable, and composed, and defaults all three to false.8 The third one is the one that matters here, because composed controls whether an event can cross a shadow boundary at all.

this.dispatchEvent(
  new CustomEvent('value-change', {
    detail: { value: 'hello' },
    bubbles: true,
    composed: true,
  }),
)

Lit treats events as the normal way elements communicate upward, in a “data down, events up” model.1117 Stencil is explicit that there is no such thing as a Stencil event: its decorators define and emit ordinary DOM events.18

The consequence is easy to state and easy to forget. Event names and event payloads are public API. Renaming value-change to valueChange is not a refactor. It is a breaking change to every listener in the estate, and unlike a renamed TypeScript export, nothing will fail to compile. It will just silently stop firing.

Retargeting is the other Shadow DOM behavior worth knowing by name. When an event crosses a shadow boundary, the DOM’s retargeting rules change what an outside listener sees as event.target: it points at the host rather than the internal node actually clicked.8 This protects encapsulation and surprises everybody once. You don’t need to memorize the algorithm, just know it exists and reach for composedPath() when an event arrives with a target you didn’t expect.

Forms, and knowing when not to build a control

The HTML Standard defines form-associated custom elements and the ElementInternals capabilities that let a Custom Element participate in native form behavior properly, rather than shadowing it with a hidden input.7 Stencil documents support for form-associated components and their lifecycle callbacks.19

class UiRating extends HTMLElement {
  static formAssociated = true

  #internals = this.attachInternals()
  #value = ''

  set value(nextValue) {
    this.#value = String(nextValue)
    this.#internals.setFormValue(this.#value)
  }

  get value() {
    return this.#value
  }
}

customElements.define('ui-rating', UiRating)

Before building any of it, ask whether a native control already solves the problem. Native controls arrive with years of browser, form, keyboard, mobile, and assistive-technology behavior you’d otherwise reimplement at your own expense, on your own timeline, with your own bugs. A custom rating widget is reasonable to build. A custom checkbox usually isn’t.

Lit, and what it teaches you

Lit describes itself as a library for building lightweight Web Components. Lit components are standard Web Components; what Lit adds is a reactive base class, declarative templates, scoped styles, and a set of conveniences on top.10 The browser supplies Custom Elements, the DOM, Shadow DOM, and events. Lit supplies less boilerplate around them.

import { LitElement, css, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'

@customElement('ui-button')
export class UiButton extends LitElement {
  @property({ reflect: true })
  variant: 'primary' | 'secondary' | 'danger' = 'primary'

  static styles = css`
    button {
      font: inherit;
    }
  `

  render() {
    return html`
      <button type="button" data-variant=${this.variant}>
        <slot></slot>
      </button>
    `
  }
}

The lifecycle, and where work belongs

Lit batches reactive updates asynchronously rather than rendering once per assignment, so “when does this run” has a real answer worth knowing.1220

constructor

The element exists but is not in the document. Set defaults. Do not touch the DOM or read layout.

connectedCallback

Now it is in the document. Subscribe to things you do not own, and remember that this can run more than once per element.

willUpdate

Before rendering. Compute derived values here so render stays a pure description of output.

render

Return the template. No side effects, no fetching, no measuring.

firstUpdated

First render only. The shadow tree now exists, so this is where one-time DOM setup goes.

updated

Every render after work is committed. Read layout here if you must, and expect to pay for it.

Lit also exposes updateComplete, a promise resolving around the end of the update cycle. Its documentation recommends awaiting a completed render before emitting events whose listeners need to observe the new rendered state.20 Learn the lifecycle well enough to answer “when should this work happen,” not well enough to recite the names.

Public property or internal state

Lit separates public reactive properties from internal reactive state.12 The decorator you pick is an architectural declaration, not a style preference.

@property()
value = ""; // public API. You now own this for years.

@state()
private validationMessage = ""; // implementation detail. Yours to change.

If consumers do not need to set it or read it, do not make it public. Every public field is another behavior somebody will depend on, discover by accident, and file a bug about when it changes.

Reactive Controllers

Reactive Controllers package reusable state, behavior, and lifecycle participation so it can be shared across components.21 They earn their place for cross-cutting concerns: localization, resize observation, shared keyboard behavior, state synchronization. They stop earning it the moment they become the place unrelated component logic goes to hide. A controller that four components use is infrastructure. A controller that one component uses is a file you moved code into.

For an engineer who already knows Stencil, Lit is high-leverage as a second implementation model precisely because it forces the separation: after building the same component twice you can tell which ideas belonged to Stencil and which belonged to Web Components. That knowledge survives your next job change. Framework fluency does not.

React is no longer the problem it was

The old line about React being unable to consume Web Components is out of date. React 19’s release notes state that it added full support for Custom Elements and passes the Custom Elements Everywhere suite, and the current React DOM documentation covers custom-element event handling including CustomEvent through JSX props.2223

What is still true is that interoperability and ergonomics are different questions. A component can work correctly in React and still feel foreign to a React team, which is what a thin adapter is for. @company/components publishes <ui-button> and owns the behavior; @company/react publishes Button and adapts that public API to React conventions of naming, typing, refs, and event props. Lit publishes @lit/react for exactly this.24

import * as React from 'react'
import { createComponent } from '@lit/react'
import { UiButton } from '@company/components/button.js'

export const Button = createComponent({
  tagName: 'ui-button',
  elementClass: UiButton,
  react: React,
})

Keep adapters thin. Once a wrapper contains its own interaction model, its own state, or its own opinions about behavior, you no longer have a design system with a React adapter. You have two design systems, and only one of them has tests.

Migrating Stencil to Lit

Stencil’s compiler reads metadata from decorators like @Prop() and its event and lifecycle declarations, then compiles component output.91318 Lit uses standard Web Components with its own reactive-property and rendering layer.1012 The migration is not a find-and-replace from one decorator vocabulary to another. Framing it that way is how teams ship a rewrite that breaks every consumer and calls itself a refactor.

The objective that survives contact with reality: replace the implementation and preserve the consumer contract, unless there is a deliberate, argued reason to change it. That means the first deliverable is not code. It’s an inventory, per component, of everything a consumer can currently observe:

  • Tag name
  • Attributes
  • Properties
  • Property types
  • Reflection behavior
  • Methods
  • Events
  • Event payloads
  • Bubbling and composition
  • Slots
  • CSS custom properties
  • CSS parts
  • Default content
  • Keyboard behavior
  • Focus behavior
  • ARIA and semantics
  • Form behavior

The same component, twice

Here is a toggle in Stencil:

import { Component, Event, EventEmitter, h, Prop } from '@stencil/core'

@Component({
  tag: 'ui-toggle',
  shadow: true,
})
export class UiToggle {
  @Prop({ reflect: true }) checked = false

  @Event({ bubbles: true, composed: true })
  checkedChange!: EventEmitter<boolean>

  private toggle = () => {
    this.checkedChange.emit(!this.checked)
  }

  render() {
    return (
      <button
        type="button"
        aria-pressed={String(this.checked)}
        onClick={this.toggle}
      >
        <slot />
      </button>
    )
  }
}

And in Lit:

import { LitElement, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'

@customElement('ui-toggle')
export class UiToggle extends LitElement {
  @property({ type: Boolean, reflect: true })
  checked = false

  private toggle() {
    this.dispatchEvent(
      new CustomEvent<boolean>('checkedChange', {
        detail: !this.checked,
        bubbles: true,
        composed: true,
      }),
    )
  }

  render() {
    return html`
      <button
        type="button"
        aria-pressed=${String(this.checked)}
        @click=${this.toggle}
      >
        <slot></slot>
      </button>
    `
  }
}

The code is unrecognizable. The consumer sees the same tag, the same reflected attribute, the same event name and payload, the same slot, the same keyboard behavior. That’s the entire trick, and it only works because someone wrote the contract down first.

This example is also deliberately small. A real migration has to account for event casing conventions, generated type definitions, framework bindings, style output, hydration strategy, form behavior, test utilities, build targets, and package structure. None of that shows up in a toggle.

An incremental sequence

Unless there’s a business reason for a rewrite, migrate a large shared library in slices, keeping consumers whole while the implementation changes underneath them.

Inventory the current public contracts

Everything in the list above, per component, written down where other people can read it.

Add contract tests to the existing components

Written against consumer-visible behavior, not internals. These are what let you swap implementations at all.

Pick one low-risk component

Something with few consumers and no form or focus behavior. You are testing the process, not your Lit skills.

Reimplement it, and run both through the same tests

One suite, two implementations. Any divergence is a contract change you did not intend.

Publish a prerelease and validate real consumers

Not a demo application. A real product, with its real build, in its real framework.

Migrate in slices, then remove the old implementation

Removal comes after adoption, which you can only confirm if you are measuring it.

The Staff-level idea in that sequence: the migration has consumers, not just source files. Every decision in it is really a decision about somebody else’s sprint.

Contracts: TypeScript, packages, versions

Types are how an API explains itself

The TypeScript Handbook documents unions, narrowing, generics, and utility types as mechanisms for describing and constraining JavaScript APIs.25262728 These matter more in a design system than in application code, because a public component API gets reused by people who will never read its source.

A union closes a set:

type ButtonVariant = 'primary' | 'secondary' | 'danger'

const variant: ButtonVariant = 'banana'
// Type error

A discriminated union goes further and makes invalid combinations unrepresentable, so the type describes which data is valid in which state:

type LoadState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

function renderState<T>(state: LoadState<T>) {
  switch (state.status) {
    case 'success':
      return state.data

    case 'error':
      return state.error.message
  }
}

There is no state in which data exists and status is 'error', because the type will not let you construct one. TypeScript’s narrowing model refines the union from the discriminant at each branch.26

Generics, and how open to leave the door

Generics describe a relationship between types while preserving information about the values passing through.27 Same shape, different data:

interface SelectOption<TValue> {
  label: string
  value: TValue
}

const numberOption: SelectOption<number> = { label: 'Five', value: 5 }
const stringOption: SelectOption<string> = { label: 'Connecticut', value: 'CT' }

The harder decision is how closed to make a type at all:

type Size = 'small' | 'medium' | 'large'
// versus
type Size = string

The first gives autocomplete, catches typos, and forbids anything you didn’t anticipate. The second is extensible and lets 'smal' ship to production. There’s no universal winner: the real question is whether consumers are expected to extend this value space. Close it for strict visual consistency. Leave it open if teams legitimately need sizes you haven’t thought of, since otherwise they stop using the prop and reach for a class instead, and you lose both consistency and the signal that they needed something.

Staff-level TypeScript is less about knowing every utility type and more about designing public APIs that communicate intent, reject invalid states where practical, and stay legible to somebody who only reads the autocomplete.

A design system is not distributed by magic

Node and npm use package.json to describe entry points, dependencies, and module behavior, and Node supports ECMAScript modules as an official format.293031 The exports field defines which entry points consumers are allowed to import, and Node’s own publishing guidance warns that adding exports to an existing package is a breaking change if consumers were previously importing undeclared deep paths.32

That warning is the whole chapter in miniature: a four-line change to a config file, and every application that reached into your dist/ folder stops building.

{
  "name": "@company/components",
  "version": "5.2.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./button": {
      "types": "./dist/button.d.ts",
      "import": "./dist/button.js"
    }
  },
  "types": "./dist/index.d.ts",
  "sideEffects": false
}

The exact shape follows your build output. The architectural question does not: which imports have we promised to keep working? The moment your documentation contains import '@company/components/button', that path is public developer experience, whether or not anyone wrote it down as API.

Dependencies are architecture

npm documents the dependency fields, and the mental model is short.3330 dependencies are needed at runtime. devDependencies are needed to build, test, or develop the package. peerDependencies declare a compatibility relationship with something the consuming environment supplies.

A React adapter declares React as a peer rather than shipping its own copy, because two React runtimes in one application is a class of bug nobody enjoys diagnosing:

{
  "peerDependencies": {
    "react": ">=19"
  }
}

These choices decide duplicate runtimes, bundle size, install behavior, upgrade constraints, and how much support you end up giving. They are made in a config file and felt in every consuming application.

Versioning is a promise about a surface you have to define

Semantic Versioning defines MAJOR.MINOR.PATCH once a public API is declared: incompatible changes bump major, backward-compatible features bump minor, backward-compatible fixes bump patch.34

What SemVer won’t do is tell you what your public API is, and for a design system that reaches well past TypeScript signatures: the same surface listed earlier (attributes, events and their payloads, CSS hooks, slots, keyboard behavior, semantics, import paths, framework adapters, or visual output products built layouts around) can all be breaking. Tightening a button’s default padding by 4px ships as a patch in most teams and breaks three product layouts by Tuesday.

One package, or many

There is no standard answer, and the tradeoffs are real in both directions.

ShapeWhat you getWhat it costs
One package (@company/design-system)Simple discovery. Versions always in step. One dependency for consumers to reason about.Broad releases. Every change feels coupled to every consumer, whether or not it is.
Layered (tokens, icons, components, react)Clear architectural boundaries. Adapters evolve independently. Tokens can serve non-web consumers.Compatibility between layers becomes something a human has to manage.
One per component (button, dialog, select)Genuinely independent install and release.Version sprawl, expensive dependency coordination, and real discoverability cost.

For most enterprise design systems, a small number of deliberately layered packages is the right starting point. Split when a boundary actually exists, not because the monorepo tool made splitting cheap.

Breaking changes need a migration system, not a version number

SemVer announces that a break happened.34 It does not perform the migration, and the version number is the least useful part of the process to a team with forty applications and a quarter of committed roadmap.

Proposal, and consumer impact analysis

Who breaks, how badly, and how many of them are mid-release when it lands.

Deprecation period with a replacement already shipped

Deprecating an API before its replacement exists just tells teams to feel bad about code they can’t change yet.

Migration guide, and a codemod where it is practical

A guide converts your afternoon into their afternoon. A codemod converts it into nobody’s.

Prerelease, validated against real consumers

The point is to find what your inventory missed while it’s still cheap to find.

Major release, then adoption tracking

Publishing isn’t landing. You won’t know the change succeeded until you can see who’s on it.

Removal of the old API

Last, and only once tracking says it’s safe. A deprecated API you never remove is a permanent API with a warning on it.

A system used by a hundred applications needs a different policy from one used by three. The failure mode is running the hundred-application process at three applications, which reads as bureaucracy, or the three-application process at a hundred, which reads as chaos.

Accessibility, testing, and browser support

WCAG and the APG do different jobs

WCAG 2.2 is a W3C Recommendation containing testable success criteria for web content.3536 The WAI-ARIA Authoring Practices Guide provides patterns and examples for common widgets and interaction models.3738 WCAG asks what outcomes accessible content must satisfy. The APG asks how a given interactive pattern might be built. Neither is a certification stamp: an APG example is a reference implementation of one pattern, not a guarantee about your product, your semantics, or your users.

Accessible behavior is part of the contract

A component isn’t accessible because it renders <div role="dialog">. The interaction model is the accessibility. For a dialog that means the trigger activates it, focus moves somewhere sensible, keyboard interaction works, background interaction is handled, and on close focus returns to where the user left it.38 The best accessibility work in a design system makes the safe behavior the default, so using the component correctly is easier than using it incorrectly.

Prefer native semantics wherever they fit. When the interaction really is a button:

<!-- not this -->
<div role="button" tabindex="0">Save</div>

<!-- this -->
<button type="button">Save</button>

ARIA is valuable and sometimes unavoidable. Using it to reconstruct semantics HTML already provides is how a design-system team acquires behavior it now has to maintain forever, for nothing in return.

Automated testing is a guardrail, not proof

Playwright’s accessibility documentation is plain about this: automated tests catch common problems, many issues need manual testing, and the recommendation is to combine automated checks with manual assessment and inclusive user testing.39 Storybook can run the same checks in component development and CI.40

import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'

test('dialog has no automatically detectable violations', async ({ page }) => {
  await page.goto('/components/dialog')

  const results = await new AxeBuilder({ page }).analyze()

  expect(results.violations).toEqual([])
})

A green result tells you a specific set of rules found nothing. It doesn’t tell you whether the keyboard flow makes sense, whether the label is comprehensible, or whether a screen-reader user’s path through the task is efficient. Every one of those has shipped green.

Different tests answer different questions

Storybook is built for developing and documenting components in isolation; Playwright runs end-to-end across Chromium, Firefox, and WebKit through one API.4142 A shared UI system usually wants several layers, because each answers something the others can’t.

LayerThe question it answersIn practice
UnitDoes this small function behave correctly?expect(normalizeSize('SMALL')).toBe('small')
Component renderDoes it render the right state for the right input?Mount with properties, assert the output tree
InteractionDoes clicking, typing, opening, dismissing work?Driven interaction against a rendered component
AccessibilityDo automated rules find common violations?axe against each rendered state
BrowserDoes it work in the engines we committed to?The same suite, three engines
ContractDoes the public API still behave as promised?Set the property, assert the attribute and the event
Visual regressionDid the appearance change without anyone deciding to?Screenshot diff per component state

Contract tests are the highest-leverage row for a platform team: they’re what lets the implementation move while consumer behavior stays still, and the reason the Stencil-to-Lit migration described earlier is possible at all.

Say the documented contract is: changing the open property on <ui-dialog> makes the dialog visible, and closing it emits ui-close. The test is written against that sentence, not against Lit:

import { test, expect } from '@playwright/test'

test('dialog public contract', async ({ page }) => {
  await page.setContent(`
    <ui-dialog id="dialog">Hello</ui-dialog>
    <script type="module" src="/dist/ui-dialog.js"></script>
  `)

  await page.locator('#dialog').evaluate((element) => {
    element.open = true
  })

  await expect(page.locator('#dialog')).toHaveAttribute('open')

  // Continue with the documented close interaction
  // and verify the documented public event.
})

Nothing in that test knows which library rendered the dialog. That’s the point.

Browser support is a product decision

Playwright supports testing Chromium, Firefox, and WebKit, covering the major engine families in automated runs.4243

Don’t start compatibility planning by asking what polyfills you need. Start by asking which browsers and product environments the organization has actually promised to support: that answer determines the polyfills, feature detection, fallbacks, and which high-risk interactions get checked by hand.

Then write the policy down and publish it. Every product consuming the system inherits those decisions whether or not they were told, and an unstated policy gets discovered during an incident.

Tokens, governance, and adoption

Design tokens finally have a stable interchange format

The Design Tokens Community Group, a W3C Community Group, published the first stable Design Tokens Format Module in October 2025, defining a file format for exchanging tokens between tools.4445 Its technical material describes tokens as individual design-system values: color, spacing, typography.46

{
  "color": {
    "action": {
      "primary": {
        "$type": "color",
        "$value": {
          "colorSpace": "srgb",
          "components": [0.1, 0.3, 0.8],
          "alpha": 1
        }
      }
    }
  }
}

Follow the applicable DTCG version rather than that simplification. What matters more than the syntax: a token is a named design decision that can travel between tools and platforms without a human retyping a hex value into a second system and introducing a third one by accident.

Figma Code Connect is a bridge, not a source of truth

Figma’s Code Connect documentation describes it as a way to connect code components in a repository to their Figma counterparts in Dev Mode, with documented support for HTML and Web Components.4748

It creates a link between a Figma component, the design intent behind it, a component API, and an implementation. It doesn’t create an authority. Neither side is automatically correct: sometimes the design model is stale, sometimes the code API is wrong, and sometimes the two model genuinely different concepts and need an explicit translation between them.

That’s what makes design-code mismatch a Staff-level problem rather than a ticket. Resolving it usually requires design and engineering to agree on what the thing is, and no amount of renaming a prop substitutes for that conversation.

Governance is just “how the system decides”

The word sounds bureaucratic. In practice it answers a short list of questions that would otherwise get re-litigated every quarter: who can add a component, who owns the API, what counts as breaking, how proposals get reviewed, who signs off on accessibility, how deprecated APIs get removed, and how design and code stay in step.

Good governance reduces repeated arguments. Bad governance either blocks everything or lets the system drift into inconsistency, and both failure modes look like “we have a process.”

A lightweight proposal flow is usually enough: problem, evidence of consumer need, proposed API, alternatives, accessibility and design review, engineering review, prototype, decision. Not every button change needs an RFC. A change to how every button in the company reports its state probably does.

Decisions worth remembering get an Architecture Decision Record, which can be very small:

# ADR: Dialog uses native <dialog> internally

## Status

Accepted

## Problem

We need a consistent modal-dialog primitive.

## Constraints

- Must support keyboard users.
- Must work in supported browsers.
- Must expose a stable Web Component API.
- Must allow future internal replacement.

## Decision

Use the native <dialog> element inside the component.

## Alternatives considered

- Custom fixed-position div
- Third-party dialog primitive

## Consequences

Positive:

- More platform behavior is delegated to the browser.

Negative:

- We must validate browser behavior against our support policy.

The value isn’t the template. Eighteen months later, when someone proposes replacing the internals, they can find out why the decision was made instead of re-deriving it or assuming it was arbitrary. Leave enough context that the next engineer can disagree with you knowledgeably.

Adoption is not solved by telling teams to comply

A design system can be technically excellent and still fail. When adoption is weak, diagnose before prescribing: “we need better documentation” is the answer teams reach for regardless of the actual problem. Nine questions, and what a “no” to each one is really telling you:

AskWhat a “no” actually means
Do teams know the system exists?A communications problem, and the cheapest one on this list to fix.
Does it contain what they need?A roadmap problem. They are not refusing the system, they are routing around a gap.
Can they understand the API?A design problem in the API itself. Documentation is the symptom, not the cause.
Can they install it without help?A packaging problem. Entry points, peer dependencies, or build assumptions.
Does it work with their framework?An architecture problem, and the most expensive one to discover late.
Can they customize it enough?A governance problem. Your constraints and their product needs have diverged.
Do releases break them?A versioning problem. Trust, once spent here, takes several clean releases to earn back.
Can they get help when stuck?A staffing problem wearing a documentation costume.
Is migrating cheaper than staying?An economics problem. Until this one flips, nothing else on the list matters.

Exactly one of those rows is documentation.

Developer experience is the product surface

Storybook’s model centres on building and documenting components in isolation, which is why it’s the common tool here.41 But the documentation site is one part of a longer surface: package installation, import paths, TypeScript autocomplete, naming, examples, error messages, release notes, migration docs, framework adapters, local development speed, debugging, the contribution workflow, and where someone goes when they’re stuck at 4pm.

The bar for a component API is that an ordinary product engineer can use it correctly without knowing how the design-system team built it. If using it correctly requires understanding the implementation, the API is the bug.

Measuring whether any of it worked

Useful signals are mostly unglamorous: applications on the system, package versions deployed, the lag between a release and consumer upgrade, deprecated API usage, component usage, support request volume, migration completion, accessibility defects originating in shared components, and build or bundle regressions.

Every one needs context. Low adoption can mean the system is failing, or that it’s deliberately scoped to one product family. Read in groups, they answer three different questions:

LensThe question it answersSignals
Consumer successCan product engineers build correct interfaces faster, with less repeated decision-making?Adoption, time to implementation, support burden, successful upgrades, developer feedback
End-user successAre the experiences accessible, consistent, and reliable for the people actually using them?Accessibility defect rates, usability findings, consistency audits, interaction failures
System healthCan the platform keep changing safely?Upgrade latency, release reliability, test stability, deprecation backlog, unresolved architectural debt

Three lenses, because a team measuring only the first optimizes for shipping components, and a team measuring only the third optimizes for never shipping anything.

“We have 87 components” says nothing about whether any of them are useful, accessible, maintainable, or used.

Deciding, and getting others to follow

Do not open an architecture question with a technology

Someone asks how you’d build a design system for fifty applications. The weak answer is “I would use Lit.” Lit may well be right, but nothing yet established makes it right.

Start by discovering constraints. What frameworks consume the system? Is server rendering required? Which browsers are supported? What already exists, and who depends on it? How many teams, and how independent are they? How quickly can consumers upgrade, given their release process? What accessibility standard does the organization target, and is it a legal obligation? Are there native or mobile consumers of the same tokens? How are packages published? Is the organization optimizing for strict consistency or product flexibility? Who owns governance?

Let the architecture follow from the answers.

A repeatable decision model

Consumer, problem, constraints, options, tradeoffs, decision, rollout, measurement. In that order, every time.

Take a real question: should the shared system be React components or Web Components?

Consumer and constraints

Thirty applications. Twenty React, six Angular, four framework-light. One visual language, accessibility consistency required, long product lifetimes, some server-rendering needs.

None of this is a technology preference yet, just facts about the estate.

Options on the table

  • React-only core
  • Web Component core with adapters
  • A separate implementation per framework
  • Shared headless logic with framework-native view layers

Each moves interoperability, ergonomics, SSR strategy, team expertise, package complexity, and maintenance cost in different directions.

The decision comes after that, and it’s defensible because everyone can see what it was weighed against. Staff-level interviewing is mostly about demonstrating which facts determine the answer, not naming the interviewer’s preferred technology.

Influence travels through artifacts

GitLab’s Staff and Principal frameworks emphasize architectural leadership, standards, mentorship, cross-team work, and enabling others.23 Without direct reports, all of that has to travel through things you make: an RFC, an ADR, a prototype, a migration guide, an API specification, an accessibility contract, a reference implementation, a code review, a test harness, a release plan, documentation.

The best Staff engineers don’t make themselves indispensable by holding knowledge. They make their judgment reproducible, in tools and standards and examples, so the same decision gets made correctly in rooms they’re not in. That’s also the difference between mentoring and being an answer machine. Compare:

Answering

”Change this to composed: true.”

Correct, fast, and it buys exactly one fixed bug. The engineer learns that you know things.

Mentoring

”Does this event need to be observed outside the shadow tree? If so, let’s look at how composed affects the boundary.”

Slower once. The engineer solves the next four event problems without you.

What not to over-optimize

Three habits are worth resisting, because each one looks like diligence.

You do not need to master every frontend framework. A UI-platform engineer gets far more leverage from deep platform knowledge plus strong adaptation skills. Go deep on HTML, CSS, JavaScript and TypeScript, accessibility, component and API design, and browser behavior. Stay strong on Lit or Stencil, React interoperability, testing, package tooling, and design tooling. Working knowledge is enough for other consumer frameworks, build systems, and SSR environments. Frameworks change. Platform concepts travel.

Complexity is not seniority. Nobody demonstrates Staff-level ability by selecting the most sophisticated available architecture. Sometimes the correct decision is to use <button>, or to not build the component, or to keep the old API for another year because migration costs more than the benefit is worth. Judgment includes knowing when not to introduce an abstraction.

Not every problem is a design-system problem. Some UI is legitimately product-specific. A design system should absorb repeated organizational patterns, not every unique feature that happens to look tidy in a shared repo. Ask whether multiple consumers will genuinely benefit, or whether product logic is being moved into shared infrastructure because it feels neater there. The wrong abstraction creates more coupling than the duplication it replaced.

Practising this on your own

The highest-value practice project for this specialization is not a todo application. It is a very small component platform, built specifically so that you have to solve platform problems rather than feature problems.

ui-system/
├── packages/
│   ├── tokens/
│   ├── components/
│   └── react/
├── apps/
│   ├── docs/
│   └── react-demo/
└── tests/

Four components is enough: ui-button, ui-text-field, ui-alert, ui-dialog. Not because four is a magic number, but because those four force progressively harder concerns in order. The button is semantics. The text field is form participation. The alert is announcement and status. The dialog is focus management and modality, which is where most component libraries quietly give up.

Build one component with no framework at all

A native Custom Element. Lifecycle, attributes and properties, event dispatch, Shadow DOM, slots, styling boundaries. Then rebuild the same component in Lit. The comparison is the actual lesson, and you only get it by doing both.

Build the other three in Lit

With typed properties, documented events, slots, CSS custom properties, deliberately chosen parts, real accessibility behavior, stories, and tests. Lit’s documentation is the reference here, not a tutorial from 2021.

Package it properly

Three packages: tokens, components, react. Practise exports, declaration files, ESM, versioning, peer dependencies, and installing it somewhere else. Use the Node, npm and SemVer documentation as your source of truth rather than copying an old package template off a blog.

Consume it from React, twice

Use the Custom Elements directly first. Then add an adapter. Then answer honestly whether the adapter improved anything, because that is the question you will be asked in an interview.

Break it on purpose

Rename variant=“danger” to intent=“critical”, then do the work a platform team would owe its consumers: explain why, deprecate the old API, warn at the right moment, write the migration guide, consider a codemod, ship a prerelease, update the adapter, the tests, and the examples.

That last stage teaches more Staff-level thinking than building ten additional visual components, because it is the only one where the hard part is other people.

A ten-week version

If you want the same thing on a schedule, this is the sequence I would run. The deliverable column is the part that matters; the study column is just what you need in order to produce it.

WeeksFocusDeliverable
1-2The browser platform. Custom Elements and their lifecycle, Shadow DOM, slots, DOM events, bubbles and composed, retargeting, attributes versus properties, CSS custom properties, ::part, ElementInternals, declarative Shadow DOM.7815One useful native Custom Element, built with no component framework.
3-4Lit. LitElement, templates, reactive properties, internal state, lifecycle, events, styles, controllers.10122021The four-component mini design system.
5TypeScript API design. Unions, narrowing, discriminated unions, generics, utility types, public versus internal types.252627Every public property and event payload typed, with a written reason each type is open or closed.
6Accessibility architecture. WCAG 2.2, the APG patterns relevant to your four components, keyboard behavior, focus management, names and descriptions, the limits of automation.3537An accessibility contract per component, verified by hand and by machine.
7Packaging. npm package anatomy, ESM, exports, dependencies, peer dependencies, SemVer.29323034The components packaged and consumed from a genuinely separate project.
8React interoperability. Current Custom Element behavior, event integration, refs, typing, wrapper tradeoffs.222324The system supported from React both directly and through an adapter, with the adapter’s existence justified in writing.
9Governance and migration.One ADR, one RFC, one breaking-API proposal, one migration guide, a Stencil-to-Lit plan, and a versioning policy.
10Interview simulation. System design, component API critique, accessibility architecture, packaging, migration, behavioral stories, technical disagreement, ambiguous requirements.Your reasoning, out loud, repeatedly. Not rehearsed speeches.

A self-check

Three groups. None of this is a certification, and nobody is going to ask you to produce it. It is useful for the same reason a preflight checklist is useful: the gaps are invisible until you look for them deliberately.

Technical depth

Can you explain, without reaching for a framework’s vocabulary:

  • Custom Elements, without using Stencil or Lit terminology
  • The tradeoffs of Shadow DOM, in both directions
  • Attributes versus properties, and when to reflect
  • Event bubbling, composition, and retargeting
  • Component lifecycle, and what work belongs where
  • Form-associated custom elements and ElementInternals
  • Accessibility semantics and keyboard behavior
  • TypeScript API design, and when to close a type
  • Package entry points and dependency kinds
  • SemVer, and what counts as breaking in a design system
  • A browser testing strategy and why it has those layers

Platform thinking

Can you:

  • Define a component’s public contract, completely
  • Design a migration that does not require rewriting every consumer
  • Decide what belongs in the core versus an adapter
  • Explain how the library is packaged and released
  • Diagnose low adoption without defaulting to “better docs”
  • Design a support model that does not depend on you
  • Write contribution and governance rules people will follow
  • Measure system health across more than one lens
  • Explain a tradeoff instead of naming a preference

Influence

Can you:

  • Lead a technical decision without managing anyone
  • Write a proposal other teams can act on
  • Mentor through reasoning rather than answers
  • Hold design and engineering concerns at the same time
  • Challenge a requirement constructively
  • Operate when the requirements are incomplete
  • Identify which problem actually matters most right now
  • Leave the organization better able to solve the next one

Finding the right role

Titles worth searching

The title matters far less than the work, but you have to type something into the search box:

  • Staff Design Systems Engineer
  • Principal Design Systems Engineer
  • Staff UI Engineer
  • Principal UI Engineer
  • Staff Frontend Engineer, Design Systems
  • UI Platform Engineer
  • Staff UI Platform Engineer
  • Principal UI Platform Engineer
  • UX Engineer
  • Staff UX Engineer
  • Principal Product Design Engineer
  • Design Systems Architect

Reading the job description

A real platform role gives one person responsibility across several of these: shared component architecture, Web Components or platform standards, accessibility, design tokens, component APIs, package publishing, semantic versioning, documentation, Storybook, cross-framework consumers, React adapters, Figma integration, browser support, testing infrastructure, governance, adoption, migration strategy, developer experience, technical specifications, cross-team architecture, and mentoring.

The faster tell is the language. Job descriptions leak scope:

Platform scope

  • ”used across multiple products"
  • "shared UI platform"
  • "framework-agnostic architecture"
  • "component distribution and versioning"
  • "design-system governance"
  • "drive adoption across engineering teams”

Every one of these implies consumers who are not you, which is the whole distinction.

Feature scope, relabelled

  • ”implement Figma designs in our React app"
  • "own our component library” with no consumers named
  • ”pixel-perfect implementation"
  • "maintain our CSS framework"
  • "partner with design to deliver features”

Also: application state management, feature delivery with occasional component cleanup, or people management wearing a technical title.

There is nothing wrong with the right-hand column. Those are good jobs, and simply a different career. Taking one while expecting the other is how people end up frustrated eighteen months later. The same goes for a short contract when what you want is stability: no amount of technical fit fixes that mismatch.

Where these problems concentrate

An organization with many products, many product teams, long-lived applications, accessibility obligations, several frontend frameworks, and one shared brand has structural reasons to fund a UI platform. An organization with two products and one team does not, and should not.

That points toward large technology firms, financial services, insurance, healthcare, enterprise SaaS, retail platforms, and government-facing systems. It says where the problems live, not that every company there has a mature design-system team. Plenty have the problem and nobody assigned to it, which is either the opportunity or the warning depending on how the rest of the interview goes.

How to describe yourself

“Frontend engineer” is accurate and tells a hiring manager almost nothing. If your differentiators are component systems, accessibility, cross-framework UI, and design-engineering infrastructure, then something like Design Systems & UI Platform Engineer carries far more signal. The specialization is an intersection: close enough to design to argue about what a component should do, close enough to engineering to ship the thing that does it.

An introduction should explain that specialty rather than recite your resume. Career foundation, specialization, technical depth, the organizational problems you solve, why this role fits:

“My background is in frontend engineering, but over time my work has concentrated on design systems and shared UI infrastructure. I focus on accessible component APIs, Web Components, developer experience, and the engineering systems needed to distribute components across products. What interests me about Staff-level UI-platform work is solving those problems once at the system level, instead of repeatedly inside individual applications.”

That is an example, not a script. The structure is the reusable part.

Preparing for the interview

Prepare stories in advance so that interview time goes on reasoning rather than on remembering your own career. One story each:

StoryWhat it demonstrates
A difficult component APITechnical judgment
An accessibility problemAccessibility depth
A cross-framework problemPlatform thinking
A package or release problemDistribution knowledge
A design and code mismatchDesign-engineering collaboration
A developer-experience improvementConsumer empathy
A decision that turned out wrongLearning and judgment
An ambiguous initiativeStaff-level autonomy
A cross-team disagreementInfluence
A migrationRisk management

Structure each one as situation, constraint, options considered, decision, result, and what you learned. “Options considered” is what separates a Staff answer from a Senior one, because it is where architectural judgment becomes visible.

Expect a system-design prompt along the lines of “design a framework-agnostic component platform used primarily by React applications.” A good answer covers requirements, browser support, token architecture, Web Component contracts, the React adapter, accessibility, testing, packaging, versioning, documentation, governance, and adoption. The interviewer will learn more from which constraint you ask about first than from your final architecture.

Expect migration questions, and notice what they are really asking. How would you migrate Stencil to Lit? Move consumers from version 4 to 5? What if 20% of products cannot upgrade this year? What if accessible behavior requires a breaking API change? Every one of those is a question about consumer empathy and risk management wearing technical clothing.

And prepare a real failure. “I care too much about quality” is not a failure and every interviewer has heard it. A usable one contains a genuinely mistaken assumption. Mine: we designed an API for maximum flexibility, consumers used it inconsistently, accessibility and documentation both got harder, and we learned the system needed stronger constraints rather than more options. Staff credibility goes up when you can show your judgment changed because reality corrected it.

Questions to ask them

Ask questions that reveal the system behind the job description.

The problem

  • What problem caused you to open this role?
  • What happens if you do not fill it?

System maturity

  • How mature is the existing design system?
  • How many applications and teams consume it?
  • Which frameworks are those applications built in?

Architecture

  • Who owns component API decisions today?
  • How are packages distributed and versioned?
  • How are breaking changes handled?
  • Is a migration already underway?

Design partnership

  • How closely do product design and design-system engineering work?
  • How are design tokens managed?
  • How is Figma and code alignment handled?

Scope and success

  • How do you distinguish Senior, Staff, and Principal here?
  • What would this person decide independently?
  • A year from now, what would make you call this hire a success?

Employment structure

  • Is the role permanent or contract?
  • If contract, what duration and what conversion expectation?
  • Who is the actual employer?
  • What benefits and protections differ from direct employment?

Ask that last group early rather than late. A technically perfect role can still be the wrong opportunity if its risk profile conflicts with what you need, and finding that out at offer stage wastes everyone’s time.

The long game is not learning every part of software engineering equally. It is becoming unusually capable in one vertical slice: accessible UI, component architecture, web standards, design systems, release infrastructure, cross-framework consumption, governance, adoption, platform architecture. That is depth without a detour into people management or backend distributed systems. Not every company has every rung, so chase the responsibility rather than the label.

The simplest version of all this

If the rest of this guide is too much to hold at once, hold four layers.

The component

Can I build it correctly?

The API

Can other engineers use it correctly, without reading how it works?

The platform

Can many teams depend on it safely, over years, while it keeps changing?

The organization

Can I improve the decisions, standards, tools, and people that keep the platform healthy?

Senior UI engineering tends to be strongest in the first two. Staff UI-platform engineering increasingly operates in the second and third. Principal scope reaches the fourth. Real companies draw those boundaries differently, so treat it as a thinking tool rather than a ladder to climb.

The Staff-level UI engineer is not the person who can build the hardest dropdown. That person is valuable, and the role is more interesting than that. It is the person who asks how the component should behave, how its API should behave, how product teams will consume it, how it stays accessible, how it gets tested, how it is packaged, how changes ship safely, how teams migrate, how designers and engineers stay aligned, how anyone will know whether the system is working, and how the next engineer ends up more effective than the last one.

The transition is from building UI to engineering the platform through which an organization builds UI.

Where this comes from

Everything above that is a matter of record is footnoted, and the full reference list sits at the foot of this page. The sources are standards bodies, official framework and tool documentation, and public company engineering frameworks, in that order of preference.

If you read only eight of them, read these:

One caution about secondary material. If you find older Microsoft FAST or Fluent Web Components articles, check them against the current FAST repository before designing anything around them. Framework-specific guidance in that ecosystem has aged considerably faster than the Web Component standards underneath it, which is a decent argument for learning the standards first.

Footnotes

  1. Monzo, “My path from intern to Staff Engineer at Monzo,” Aug. 10, 2023, https://monzo.com/blog/2023/08/10/my-path-from-intern-to-staff-engineer-at-monzo 2 3

  2. GitLab, “Staff Engineer framework,” https://handbook.gitlab.com/handbook/engineering/careers/matrix/staff/ 2 3 4

  3. GitLab, “Principal Engineer framework,” https://handbook.gitlab.com/handbook/engineering/careers/matrix/principal/ 2 3 4

  4. GitLab, “Engineering Career Matrix,” https://handbook.gitlab.com/handbook/engineering/careers/matrix/ 2

  5. GitLab, “Staff Frontend Engineer,” https://handbook.gitlab.com/handbook/engineering/careers/matrix/development/dev/frontend/staff/

  6. GitLab, “Principal Engineer” public role expectations, within GitLab’s Engineering Career framework, https://handbook.gitlab.com/handbook/engineering/careers/matrix/principal/

  7. WHATWG, HTML Standard, “Custom elements,” https://html.spec.whatwg.org/multipage/custom-elements.html 2 3 4

  8. WHATWG, DOM Standard, https://dom.spec.whatwg.org/ 2 3 4 5

  9. Stencil, “Component API,” https://stenciljs.com/docs/api 2

  10. Lit, “What is Lit?”, https://lit.dev/docs/ 2 3 4

  11. Lit, “Events,” https://lit.dev/docs/components/events/ 2

  12. Lit, “Reactive properties,” https://lit.dev/docs/components/properties/ 2 3 4 5

  13. Stencil, “Properties,” https://stenciljs.com/docs/properties 2

  14. Lit, “Shadow DOM,” https://lit.dev/docs/components/shadow-dom/

  15. W3C, CSS Shadow Parts, https://www.w3.org/TR/css-shadow-parts-1/ 2

  16. WHATWG, HTML Standard, scripting and declarative shadow roots, https://html.spec.whatwg.org/multipage/scripting.html

  17. Lit, “Component composition,” https://lit.dev/docs/composition/component-composition/

  18. Stencil, “Events,” https://stenciljs.com/docs/events 2

  19. Stencil, “Form-Associated Components,” https://stenciljs.com/docs/form-associated

  20. Lit, “Lifecycle,” https://lit.dev/docs/components/lifecycle/ 2 3

  21. Lit, “Reactive Controllers,” https://lit.dev/docs/api/controllers/ 2

  22. React, “React v19,” Dec. 5, 2024, https://react.dev/blog/2024/12/05/react-19 2

  23. React, “React DOM Components,” https://react.dev/reference/react-dom/components 2

  24. Lit, “React,” https://lit.dev/docs/frameworks/react/ 2

  25. TypeScript, “The TypeScript Handbook,” https://www.typescriptlang.org/docs/handbook/intro.html 2

  26. TypeScript, “Narrowing,” https://www.typescriptlang.org/docs/handbook/2/narrowing.html 2 3

  27. TypeScript, “More on Functions,” generics material, https://www.typescriptlang.org/docs/handbook/2/functions.html 2 3

  28. TypeScript, “Utility Types,” https://www.typescriptlang.org/docs/handbook/utility-types.html

  29. Node.js, “Modules: Packages,” https://nodejs.org/api/packages.html 2

  30. npm, “package.json,” https://docs.npmjs.com/cli/v12/configuring-npm/package-json/ 2 3

  31. Node.js, “ECMAScript modules,” https://nodejs.org/api/esm.html

  32. Node.js, “Publishing a package,” https://nodejs.org/en/learn/modules/publishing-a-package 2

  33. npm, “Specifying dependencies and devDependencies in a package.json file,” https://docs.npmjs.com/specifying-dependencies-and-devdependencies-in-a-package-json-file/

  34. Semantic Versioning, https://semver.org/ 2 3

  35. W3C, “Web Content Accessibility Guidelines (WCAG) 2.2,” https://www.w3.org/TR/WCAG22/ 2

  36. W3C WAI, “WCAG,” https://www.w3.org/WAI/standards-guidelines/wcag/

  37. W3C WAI, “ARIA Authoring Practices Guide,” https://www.w3.org/WAI/ARIA/apg/ 2

  38. W3C WAI, “ARIA APG Patterns,” https://www.w3.org/WAI/ARIA/apg/patterns/ 2

  39. Playwright, “Accessibility testing,” https://playwright.dev/docs/accessibility-testing

  40. Storybook, “Accessibility testing,” https://storybook.js.org/docs/writing-tests/accessibility-testing

  41. Storybook, https://storybook.js.org/ 2

  42. Playwright, https://playwright.dev/ 2

  43. Playwright, “Best Practices,” https://playwright.dev/docs/best-practices

  44. W3C Design Tokens Community Group, “Design Tokens specification reaches first stable version,” Oct. 28, 2025, https://www.w3.org/community/design-tokens/2025/10/28/design-tokens-specification-reaches-first-stable-version/

  45. Design Tokens Community Group, “Design Tokens Format Module 2025.10,” https://www.designtokens.org/TR/2025.10/format/

  46. Design Tokens Community Group, “Technical Reports 2025.10,” https://www.designtokens.org/TR/2025.10/

  47. Figma Developers, “Code Connect,” https://developers.figma.com/docs/code-connect/

  48. Figma Developers, “HTML / Web Components Code Connect,” https://developers.figma.com/docs/code-connect/html/