We're growing our team – join us!We're growing our team!

Engineering

How Engineers Design: Full-Stack Design Systems at Ambrook

Photo of Dan Schlosser

By Dan Schlosser

Dec 23, 2021

As an early-stage company, it’s imperative that we build and test ideas quickly. And, with limited engineering and design resources, we want to be able to build high quality interfaces without robust design speccing or redlining each iteration. To accomplish this, we decided to invest early in a strong design system foundation, not just in our design tools, but also in code.

Investing early in our design system and maintaining parity between Figma and code base enables our engineering team to autonomously and meaningfully contribute to our product without needing a pixel-perfect mockup in every situation.

All of Ambrook’s products, from our marketing website to our Funding Library, cards and bookkeeping tools are built with these same components, written once by engineers then used everywhere. We’ve built our design system on a single design stack:

When used in concert, these design system components let us plug together high quality user interfaces without engineers having to worry about things not looking right.

It all begins in Figma

Ambrook uses Figma component variants to design consistent interfaces. With components, we can have a central style that gets reused everywhere. And, with Figma variants, we spec out what each possible combination of properties looks like, and then can select the size, color, lightness, hover state, etc from the sidebar easily. This means that when engineers see the button component, they know that it’s the same one that we have in code.

When constructing UIs in Figma, all we need to do is customize the properties of our components to construct interfaces. We use autolayout, which correlates directly with flexbox in code. When engineers view our Figma projects, they can inspect to see which components are used with what configurations, and translate that directly into React code.

Shared Code and Interfaces

Basic foundational values like colors are shared across components. Because our frontend, backend, and mobile app are all written in Typescript, these type definitions are shared across our stack.

We separate business logic and design implementation, creating an agnostic UI layer that we can use with multiple implementations per platform. This layer makes it easy for us to remove third-party dependencies like down the line if we need to optimize for performance, for example.

For each component in Figma, we define a React component that takes on similar properties to those outlined in Figma. For our button, that means size, color, light, disabled, etc. We prefix Ambrook design system components with Am, and we keep them in a frontend/design-system folder.

// frontend/design-system/buttons/AmButton.tsx
import { StyledAmButton } from './AmButton.style';
// ...
const AmButton = (props: AmButtonProps): JSX.Element => {
  // ...
  return (
    <StyledAmButton ... />
  );
};
export default AmButton;

This allows us to write easy-to-read code:

<AmButton size="large" color="blue" href="/careers" light>
  Click Me
</AmButton>

The AmButton component is responsible for any business logic, like support for situations where the caller passed an href (e.g. a button that is actually a link), an onClick prop (e.g. a button that submits a form), both (e.g. a button that is a link but also logs to Segment in the background), or neither (e.g. a button nested in a card which doesn’t actually do anything but passes the click event to its parent). By supporting all the different possible use cases for what a “button” can do in a single AmButton component, we ensure that all buttons look the same, regardless of what clicking them does.

Importantly, the AmButton component isn’t responsible for presentation. We have per-platform implementations, using Styled Components and Material UI on web, and React Native Paper for iOS and Android. We store these implementations in neighboring .style.tsx and .style.native.tsx files:

// frontend/design-system/buttons/AmButton.style.tsx

import Colors from 'styles/colors';
//..
export const StyledAmButton = styled(Button)<StyledAmButtonProps>`
  background-color: ${({ $color, $light }) => $light
    ? Colors[$color][10]
    : Colors[$color][50]};
  // ...
`;

The main component file is then responsible for rendering a StyledAmButton. The props prefixed with a $ will not be passed to the HTML, and will just be used within the styled component itself.

<StyledAmButton $size="large" $color="blue" href="/careers" {...moreProps} />

For some design system components, we build them directly from View components or HTML elements. For more complex ones, we wrap Material UI and React Native Paper components, which offer robust style implementations like ripples and animations, as well as accessibility features out-of-the-box.

Visualizing in Storybook

In the examples above, we’ve been using a simplified version of our components. In reality, AmButton takes over 20 different props! As components become more complex, being able to verify how components are supposed to look and work in all of their variants can be difficult. To make this easier, we’re starting to use Storybook.

Storybook plugs into the type definitions for our components, and creates properties and controls that can be played with in their pre-built UI.

We can define stories with just a few lines of code, which reduces the maintenance effort to near-zero. We store our stories next to our components, as a neighboring .stories.tsx file. Here’s what our button stories look like:

// frontend/design-system/buttons/AmButton.stories.tsx

import AmButton, { AmButtonProps } from 'frontend/design-system/buttons/AmButton';
import { Meta, Story } from '@storybook/react/types-6-0';
import React from 'react';

export const Normal: Story<AmButtonProps> = (args) => (
  <AmButton {...args}>Hello World</AmButton>
);

export const WithStartIcon = Normal.bind({});
WithStartIcon.args = { ...Normal.args, startIcon: <Decagram /> };

export const WithEndIcon = Normal.bind({});
WithEndIcon.args = { ...Normal.args, endIcon: <ArrowRight /> };

export default {
  component: AmButton,
  title: 'Components/AmButton',
} as Meta;

Our storybook components allow us to play around with the possible properties that can be provided to a component, and see how they interact.

Moving Fast

Bringing our design system into code takes time up front, as well as maintenance along the way. But so far, our investment has paid off, letting us experiment and iterate on our product without worrying about getting the design details right.

We're growing our team!

Author


Photo of Dan Schlosser

Dan Schlosser

Dan Schlosser leads engineering for Ambrook. Dan is a Columbia CS grad and a former Product Manager at Google, where he worked on Firebase and Google Drive. On the Drive team, Dan was the sole product owner for Google Drive’s 1B+ user consumer product and enterprise interoperability with legacy software. While working at Google, Dan also moonlighted as an engineer and product manager at COVID Act Now, Interact, and a number of other non-profit organizations. Before joining Ambrook, Dan managed ML personalization products at The New York Times.