Long read. Written from nine years of implementation experience, not the spec.
Preface
I believe Material Design is correct. Not partially correct -- correct in its core philosophy about how interfaces should behave under load, under interaction, under uncertainty. I follow new versions closely. I implement what I can, whenever I can. I use a Pixel phone specifically because the software is built on these principles, and without that, I would genuinely prefer not using a smartphone at all.
That conviction is also what makes me honest about the cost. I have lived on both sides of it -- the belief in the philosophy, and the two-week sprint that nearly broke me trying to apply it to a page with 12 components and 7 API calls six years into a codebase. The industry does not talk honestly about that gap. This article does.
Why I Use a Pixel Phone
I use a Pixel phone. Not because of the camera. Not because of the price. Because the software is built on Material principles, and without that, I would genuinely prefer not using a smartphone at all.
That is not hyperbole. I have used enough software to know what it feels like when a product is built by people who have internalized a philosophy versus people who have implemented a component library. The difference is not subtle. It is the difference between a product that feels like it understands you and one that feels like it is tolerating you.
Google proves this at scale. So did Microsoft -- once, with Windows Phone and Windows 7. Both products were built by teams that had a coherent philosophy about how software should behave, and both felt smooth in a way that had nothing to do with hardware specs or engineering heroics. It was ideology, made executable.
Windows Phone was killed. Not because the UX was bad -- the UX was excellent. It was killed for market and ecosystem reasons that had nothing to do with software quality. That is important. Good philosophy does not guarantee market success. But its absence guarantees UX debt. And that debt compounds silently until it becomes someone's emergency sprint.
This article is about what that sprint actually costs.
Material Is Not the Only Path. It Is One of the Most Correct Ones.
Before I go further: I am not claiming Material Design is the only valid design philosophy. I have used Azure Dashboard circa 2017. I have used WordPress. Both are smooth, both handle data-heavy interfaces competently, and neither follows Material principles as rigorously as Google's own products do.
There are other coherent philosophies. Apple's HIG is one. Fluent Design, when Microsoft actually commits to it, is another. What these share with Material is not visual style -- it is the existence of a philosophy at all. A set of answers to questions like: what happens when data is loading? What happens when an element appears? What happens when the user scrolls and content shifts? How does the interface communicate uncertainty?
Products that answer these questions consistently feel smooth. Products that don't -- regardless of how polished the visual design is -- feel unreliable.
I have used Navan. I have used insurance platforms that are clearly well-funded and well-designed at the visual layer. They are frustrating. Not broken. Frustrating. Because the answers to those questions are inconsistent, and inconsistency at interaction level is something users feel even when they cannot name it.
Material Design's contribution is that it answers these questions rigorously, in writing, with rationale. That is rare. That is valuable. And that is exactly why implementing it properly is one of the hardest things you can do in frontend engineering.
The Problem Nobody Prices In
Enterprise SaaS applications are not simple. The page I was working on had roughly 12 components, 7 API calls pulling from different data sources, a sticky table header mid-page, and a popup that triggered yet another API call. Each component mounted independently. Each API call resolved at its own time.
In Angular, the default behavior is straightforward: data arrives, you assign it to a variable, Angular renders it. Clean. Simple. Completely at odds with Material principles.
What actually happens on screen: components pop in one by one. Heights shift. The sticky header jumps. Scroll position drifts. A popup appears without transition. A button materializes where there was nothing.
Users tolerate this. They have been trained to tolerate this. That tolerance is not a design success -- it is a UX debt that accumulates silently until someone files a ticket that says "the page feels janky."
That ticket is how this sprint started.
What "No Flickering" Actually Requires
The naive fix is a spinner. Throw a loader on the page, hide everything, show everything when done. Problem solved.
Except it is not solved, because nested components have their own async lifecycles, a whole-screen loader means users wait longer in perceived time even if actual load time is identical, and when the loader disappears and 12 components render simultaneously, you still get layout shift -- just deferred by one frame.
Material's actual answer is progressive disclosure: show structure first, then fill it. Reserve space before content lands. Coordinate fade-ins. No element should appear without the space already being reserved for it.
Angular does not give you this out of the box. Material's component library, at the time, did not have a skeleton loader. So I built the coordination layer myself.
Every API call got wrapped -- not just for loading state tracking, but for orchestration. The page had a single loading gate: nothing fades in until the critical path calls resolve. Secondary data sources fill in progressively after. The whole-screen loader exists only for the first render. After that, inline loaders handle async state at the component level.
Here is a simplified version of the orchestration wrapper:
// loading-orchestrator.service.ts
@Injectable({ providedIn: 'root' })
export class LoadingOrchestratorService {
private criticalSources = new Set<string>();
private resolvedSources = new Set<string>();
private criticalResolved$ = new BehaviorSubject<boolean>(false);
registerCritical(sourceId: string) {
this.criticalSources.add(sourceId);
}
resolve(sourceId: string) {
this.resolvedSources.add(sourceId);
const allResolved = [...this.criticalSources]
.every(id => this.resolvedSources.has(id));
if (allResolved) {
this.criticalResolved$.next(true);
}
}
isCriticalResolved$(): Observable<boolean> {
return this.criticalResolved$.asObservable();
}
}
// page.component.ts
ngOnInit() {
this.orchestrator.registerCritical('job-details');
this.orchestrator.registerCritical('candidates');
this.orchestrator.isCriticalResolved$()
.pipe(filter(Boolean), take(1))
.subscribe(() => this.pageVisible = true);
this.jobService.getDetails(this.jobId).subscribe(data => {
this.jobDetails = data;
this.orchestrator.resolve('job-details');
});
this.candidateService.getList(this.jobId).subscribe(data => {
this.candidates = data;
this.orchestrator.resolve('candidates');
});
// Secondary -- does not block page fade-in
this.analyticsService.getSummary(this.jobId).subscribe(data => {
this.analytics = data;
});
}
<!-- page.component.html -->
<div class="page-wrap" [class.visible]="pageVisible">
<app-job-details [data]="jobDetails" />
<app-candidates-table [data]="candidates" />
<app-analytics [data]="analytics" />
</div>
The difficult part is nested components. When a parent is waiting, its children need to know to hold their render. When a parent resolves, children need to cascade in a coordinated way, not fire independently. This is not complex logic -- it is tedious, deliberate, component-by-component discipline. There is no shortcut.
The Scroll Flicker Problem
This is the one that took a week just to articulate.
The layout was: a fixed header at the top, a job details section taking roughly 40% of the page, a candidates table with its own sticky header taking 60%, and a footer. On scroll, the job details section collapsed -- driven by a JS scroll listener -- and the table expanded to fill the space. The problem: the collapse happened via a height snap, which opened a void in the layout, and the sticky table header recalculated its position against the new DOM in a single frame. The user saw it jump.
The animation below shows exactly what this looked like. Left side: without the fix. Right side: with it.

Left: sticky header jumps as layout void opens. Right: margin compensation absorbs the collapse -- no jump.
The fix is not a CSS trick. It is a discipline problem.
The scroll listener had to drive two things simultaneously: the job section collapsing, and the table's margin-top reducing by exactly the same delta. The margin absorbs the void before it opens. No reflow, no jump. The table header never moves relative to the viewport because the space it occupies never changes.
// scroll-coordinator.ts
const JOB_FULL_HEIGHT = 130;
const JOB_MIN_HEIGHT = 20;
@HostListener('window:scroll')
onScroll() {
const scrollY = window.scrollY;
const collapseStart = 80;
const collapseEnd = 200;
if (scrollY <= collapseStart) {
this.jobHeight = JOB_FULL_HEIGHT;
this.tableMarginTop = 0;
return;
}
if (scrollY >= collapseEnd) {
this.jobHeight = JOB_MIN_HEIGHT;
this.tableMarginTop = 0;
return;
}
const progress = (scrollY - collapseStart) / (collapseEnd - collapseStart);
const delta = (JOB_FULL_HEIGHT - JOB_MIN_HEIGHT) * progress;
this.jobHeight = JOB_FULL_HEIGHT - delta;
// Margin grows as job shrinks -- table never sees a void
this.tableMarginTop = delta;
}
The key line is this.tableMarginTop = delta. As the job section gives up height, the table gains margin by exactly the same amount. The two cancel out. From the table's perspective -- and from the user's -- nothing moved.
This sounds straightforward. Across 12 generic components with different data sources, built by different people at different times, it is not straightforward. You are retrofitting a spatial contract onto a codebase that never had one. Neither the job component nor the table component knows about each other. The page-level orchestration has to know the collapse delta and drive both simultaneously from a single scroll handler. Any timing gap between the two produces a visible jump.
The articulation took one week. This was pre-AI era. Going through the codebase, reasoning about each component's height behavior, mapping the dependency graph of API calls to visual state -- all of it manual, all of it requiring a mental model rebuilt from scratch.
The implementation took another week. The demo passed unanimously. Nothing was compromised.
That is the cost of doing it right, once, on one page, six years into a codebase.
Why It Cannot Be Retrofitted at Scale
We had approximately 500 components at this point in the product.
Applying this discipline across all of them is not a project. It is a rewrite with a different name. Each component would need its height contract audited. Each API call would need to be pulled into the orchestration layer. Every place where Angular's default change detection just assigns and renders would need to be rethought.
Some of it is not feasible at all. Components built with implicit height assumptions -- where content drives layout -- cannot simply be given explicit dimensions without redesigning the content model. The design team would need to be involved. The product team would need to sign off on the visual changes. Timelines expand immediately.
This is not a failure of will. It is geometry. The later you implement Material principles, the more surface area they touch, and the more of the product you have to rebuild to support them.
The ROI argument is real: users tolerate flickering. They have low expectations, especially in enterprise software where the alternative is a spreadsheet. For many products, especially at early stage, accepting that debt is the correct business decision.
But the debt does not stay flat. It grows with the codebase. And when the ticket finally arrives -- when a user or a stakeholder names it -- the fix is no longer a sprint. It is a quarter, if you are lucky, or a permanent compromise if you are not.
It Is Not About Code. It Is About How You Approach.
Google products are not smooth because Google engineers are better at writing JavaScript. They are smooth because the people who wrote the philosophy are embedded in the culture of the people building the product. The spec is not a reference document -- it is a shared mental model. When an engineer at Google makes a decision about loading state, they are not consulting a checklist. They are operating from an internalized answer to "what does the user know right now, and what should the interface tell them?"
That is what Microsoft had with Windows Phone. Not a better rendering engine. A coherent answer to that question, applied consistently across the product.
That coherence is what most teams are missing. Not skill. Not resources. Ideology -- and the organizational commitment to treat it as non-negotiable from day one.
When designers spec a transition, they are not speccing an animation. They are speccing a contract between data state and visual state. When a PM approves a loading state, they are not approving a spinner. They are approving the engineering time to coordinate async data across a component tree so that nothing renders before its space is reserved.
These are not implementation details. They are architectural decisions. And they need to be made before the first business component is written, not after the first complaint ticket is filed.
The Practical Conclusion
If you are starting a new product: establish the spatial and loading contract in your component architecture before you write business logic. Decide how heights are handled. Decide how API orchestration works. Decide what progressive disclosure looks like in your framework. Make it boring infrastructure now, so it is not heroic effort later.
If you are in an existing codebase: triage ruthlessly. Pick the highest-traffic, highest-complaint flows. Do the sprint. Document the contract. Use it as the template for new components going forward. Do not attempt to retrofit everything -- you will fail and the team will lose confidence in the approach.
Material is not aspirational. It is correct. The question is only when you pay for it -- at the beginning, when it is cheap, or later, when it costs everything.
Windows Phone proved you can build something philosophically excellent and still lose the market. That is a business problem. What it also proved is that philosophy-first software feels different in your hands. Users know. They may not be able to name it. But they know.
Build accordingly.
I work on frontend architecture, performance engineering, and large-scale migrations. More at dev.to/9thquadrant.
Top comments (0)