Our ops dashboard is the internal tool used by IndustryBuying’s operations team to manage supplier onboarding, order processing, inventory updates, and dispute resolution. It is not glamorous. Nobody will write a case study about its visual design. But it is used by 200 people for 8 hours a day, and when it’s inconsistent or confusing, those 200 people are less effective at their jobs.
In 2023, the dashboard had grown to 47 screens across 12 features, built by four different engineers over three years, without any shared component library. Every feature had its own implementation of a data table. Every modal was slightly different. Date pickers had three different interaction models depending on which engineer had built the feature. The inconsistency wasn’t just aesthetically unpleasant — it was genuinely slowing people down.
We spent four months building an Angular component library. It has 84 components and has been used to rebuild or extend every feature in the dashboard. Here is what we learned.
Why Not Material UI or a Third-Party Library?
We evaluated Angular Material, PrimeNG, and ng-bootstrap before deciding to build our own. The decision criteria:
Customisation depth. Our ops dashboard has domain-specific components that don’t exist in general-purpose libraries — a multi-level approval workflow visualiser, a supplier scorecard grid, a document upload status tracker. We’d be building custom components regardless. A custom library meant those custom components would share the same design tokens, theming, and API conventions as the base components.
Bundle size. Material UI for Angular imports a significant amount of CSS and JavaScript even for components you don’t use, unless you’re disciplined about tree-shaking. For an internal tool where we control the browser environment completely, we could build a leaner library tuned to our exact needs.
Team ownership. A third-party library introduces a dependency on someone else’s maintenance schedule and breaking change cadence. We’ve seen teams blocked on Angular version upgrades because a third-party component library hadn’t published a compatible version yet. Owning the library means we control the upgrade timeline.
The counterargument — “you’re rebuilding things that already exist and are well-tested” — is real. We accepted it. The trade-off was worth it for our context.
The Architecture: Design Tokens First
Every component library that succeeds does so because it has a consistent design token system. Every component library that fails does so because individual components hard-code their own values.
We defined our tokens in three layers:
Global tokens: Primitive values. These have no semantic meaning — they’re just the palette.
// tokens/global.ts
export const color = {
blue50: '#EFF6FF',
blue500: '#3B82F6',
blue900: '#1E3A5F',
gray50: '#F9FAFB',
gray500: '#6B7280',
gray900: '#111827',
// ... 48 color values
} as const;
export const spacing = {
1: '4px',
2: '8px',
3: '12px',
4: '16px',
6: '24px',
8: '32px',
} as const;
Semantic tokens: These give meaning to the global tokens. A semantic token says “this is what we use for destructive actions” rather than “this is red-600.”
// tokens/semantic.ts
import { color } from './global';
export const semantic = {
colorBrand: color.blue500,
colorDanger: '#DC2626',
colorWarning: '#D97706',
colorSuccess: '#16A34A',
colorTextPrimary: color.gray900,
colorTextSecondary: color.gray500,
colorBorderDefault: '#E5E7EB',
colorSurfaceBase: '#FFFFFF',
colorSurfaceSubtle: color.gray50,
} as const;
Component tokens: Optional layer for components with complex theming requirements. A data table, for example, has its own set of tokens for row height, cell padding, and header background — separate from the global spacing values.
The tokens are compiled to CSS custom properties at build time and consumed by components via Angular’s ViewEncapsulation.None plus scoped class names. This means a component’s styling is expressed in terms of semantic values, not hard-coded colors, and the entire dashboard can be themed by changing token values.
Component API Design: The One Rule That Matters
Every component in a design system has an API — the inputs, outputs, and content slots it exposes to consumers. Bad APIs make libraries painful to use. Good APIs make them feel like they were designed specifically for your use case.
The single rule that improved our API design more than anything else: write the consumer code before you write the component.
For every component, an engineer on the team wrote three realistic usage examples in a TypeScript file — no implementation, just how they imagined using it — before we designed the component’s interface. This forced us to think about ergonomics from the consumer’s perspective rather than the implementer’s perspective.
Here’s what that looked like for our data table:
// consumer-examples.ts — written BEFORE implementing the component
// Example 1: Simple supplier list
<ib-data-table
[data]="suppliers"
[columns]="[
{ field: 'name', header: 'Supplier' },
{ field: 'rating', header: 'Rating', type: 'rating' },
{ field: 'lastOrder', header: 'Last Order', type: 'date' }
]"
(rowClick)="openSupplierDetail($event)"
/>
// Example 2: With server-side pagination and sorting
<ib-data-table
[data]="orders"
[columns]="orderColumns"
[totalRecords]="orderCount"
[lazy]="true"
(lazyLoad)="loadOrders($event)"
[loading]="isLoading"
/>
// Example 3: With row selection and bulk actions
<ib-data-table
[data]="disputes"
[columns]="disputeColumns"
[(selection)]="selectedDisputes"
selectionMode="multiple"
>
<ng-template ibTableToolbar>
<button (click)="bulkResolve(selectedDisputes)">
Resolve Selected
</button>
</ng-template>
</ib-data-table>
Writing these examples before implementation revealed several API decisions we would have gotten wrong: we initially planned to use @Output() sort for sort events, but the consumer examples made it clear that (lazyLoad) with a unified event object (containing sort, filter, and pagination state) was far more ergonomic for the primary use case.
The Storybook Contract
We require every component to have a Storybook story before it can be merged. This is a hard rule, not a soft guideline. PRs without stories don’t get approved.
The Storybook stories serve three purposes:
Documentation. Our ops team’s frontend engineers (and designers) use Storybook as the primary reference for “what components exist and how do I use them.” Good stories with controls and descriptions eliminate the need for a separate documentation site.
Visual regression testing. We run Chromatic on every PR. If a component’s appearance changes in a way that wasn’t intentional, Chromatic flags it for review before the PR merges. This has caught three visual regressions in six months that would have gone to production unnoticed.
Accessibility audit surface. We run axe accessibility checks in Storybook’s test runner. Components with accessibility violations fail the story run and block merge. This is how we caught that our custom dropdown wasn’t keyboard-navigable and our status badges had insufficient color contrast.
// example story with accessibility testing
export const PrimaryButton: Story = {
args: {
label: 'Approve Supplier',
variant: 'primary',
disabled: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button', { name: 'Approve Supplier' });
// Verify keyboard accessibility
await userEvent.tab();
expect(button).toHaveFocus();
// Verify click handler
await userEvent.click(button);
},
};
What We Overbuilt
An honest design system retrospective has to include the things that weren’t worth building.
The animation system. We built a configurable animation system with spring physics, easing curves, and duration tokens. Nobody uses it. Ops dashboard engineers set transition: none on everything because animations slow down power users who click fast. We should have built transition: none as the default and skipped the animation infrastructure entirely.
The theme engine. We built a multi-theme system capable of supporting light mode, dark mode, and a “high contrast” accessibility mode. The ops dashboard has been in light mode only since it launched. The 200 operations staff who use it have not once requested dark mode. We shipped an architecture for a feature nobody needed.
The icon library. We built a custom SVG icon system with an Angular pipe for dynamic icon loading. Three months later, a new engineer added heroicons as a dependency for a feature and the team found it so much easier to use that we have a quiet migration underway. The custom icon system should have been “just use heroicons” from day one.
The lesson: don’t build infrastructure for imagined future requirements. Build for the actual requirements in front of you. The animation system and theme engine represent about three weeks of engineering work that has produced zero user-visible value.
The Metric That Surprised Us
When we launched the component library, we tracked adoption by measuring the percentage of UI elements in the dashboard rendered from library components vs. ad-hoc implementations.
Six months after launch: 94% of UI elements in the dashboard use library components.
The number that surprised me wasn’t the adoption rate — we mandated library-first development for new features, so high adoption was expected. The number that surprised me was the net lines of code change: −18,000.
The component library added ~12,000 lines of component implementation. We deleted ~30,000 lines of duplicated, one-off component implementations across features. A net reduction of 18,000 lines in a codebase that was around 120,000 lines is significant.
Fewer lines of code is not always better. But in this case, the deleted code was duplicated logic that accumulated bugs and needed to be updated in multiple places. The reduction is unambiguously good.
The One Rule
If I had to summarise the design system work in a single rule, it would be: a component library succeeds based on how easy it is to contribute to, not how easy it is to consume.
The consumption experience matters. But the real failure mode for internal component libraries is that they become the responsibility of one team (or one person) and the rest of the engineering organisation treats them as read-only dependencies. When a consumer hits an edge case the library doesn’t support, they either work around it (divergence starts here) or wait for the library team (velocity slows here). Neither is good.
We designed our contribution process to take 45 minutes for a new variant of an existing component and 3 hours for a new simple component. We paired every new engineer with a library contributor task during their first month. We review component PRs within 24 hours.
The library is owned by the team, not by a team. That’s why it’s still being used.
— Rohit Mishra