DEV Community

Cover image for Reusable Buttons with Angular, Tailwind & Storybook
Robin Goetz
Robin Goetz

Posted on • Updated on

Reusable Buttons with Angular, Tailwind & Storybook

What will we build?

We will build a custom Angular directive that applies our style systems button design to the element it is applied to.

Storybook view of button directive

Why a directive?

  • You get all the benefits of tailwind and its powerful styling techniques without losing the accessibility coming from the default html elements.
  • With a directive your styles are not tied to the button element. Other elements, like links/anchors, can also look like a button. This flexibility makes it easier for you to write great looking and accessible applications & websites.

Why Tailwind?

  • Its set of utility classes have good design principles built in.
  • Building your reusable UI components with tailwind gives you consistency and flexibility out of the box
  • You can use Angular’s directives and components to hide the often complained about ugly markup.

The code.

This tutorial needs an Angular project that is configured to run with Tailwind (and Storybook if you want to get the interactive demo from above).

The setup.

The easiest way to follow along is by forking my project on Github and checking out the 0-base-storybook-setup branch

You will find an NX project with an example app and a libs with a ui subdirectory, which contains the atoms directory and the storybook library. 

The storybook library allows you to run the npx nx run ui-storybook:storybook (or nx run ui-storybook:storybook if you have nx installed globally) command. This generates all stories in the ui directory. It is also configured to correctly render all Tailwind classes.

The atoms directory (based on Brad Frost’s atomic design principles finally contains our button library, the entry point of today's tutorial.

Let’s see if we wired up our storybook and tailwind config correctly.

Let's get started!

We create a simple attribute directive that adds a blue background color to our button.

Create a directory named button in the src directory.

Add the button.directive.ts file in this newly created folder.

Add the following contents to the file:

import {Directive} from "@angular/core";

@Directive({
  selector: '[natButton]',
  standalone: true,
})
export class ButtonDirective {
}

Note: As you see we are using Angulars new standalone config. If you are using Modules make sure to add your directive to the declarations and exports arrays
Enter fullscreen mode Exit fullscreen mode

Next, we will use Angular’s HostBinding decorator & a private twClasses variable to bind the tailwind utility class bg-sky-500 to our host elements class attribute

import {Directive, HostBinding} from "@angular/core";

@Directive({
  selector: '[natButton]',
  standalone: true,
})
export class ButtonDirective {
  @HostBinding('class')
  private twClasses = 'bg-sky-500'
}
Enter fullscreen mode Exit fullscreen mode

So far so good! Let’s add button.stories.ts in the same directory and write a simple story to see if storybook renders our new button directive correctly.

import {ButtonDirective} from "./button.directive";
import {Meta, Story} from "@storybook/angular";

export default {
  title: 'Design System/Atoms/Button',
} as Meta<ButtonDirective>;

export const Default: Story<ButtonDirective> = () => ({
  moduleMetadata: {
    imports: [ButtonDirective],
  },
  template: `<button natButton>Click</button>`
});
Enter fullscreen mode Exit fullscreen mode

Awesome! You just created your first story following the Component Story Format. Let’s break down the code:

  • Our default export tells storybook the metadata of our stories. Following storybooks naming convention we create our Button stories in the Atoms directory of our Design System project.
  • We then export the Default story for our ButtonDirective. To configure the story we add the directive to the moduleMetadata array and the button element in our template.

Lets run the npx nx run ui-storybook:storybook command and see if we get our Click button with the sky blue background.

We open localhost:4400, navigate into the Atoms folder and select our Default Button Story.

Storybook UI displays Click button with blue background

Awesome! Let's take this button to the next level.

Making it real.

So far our button is not very impressive.

So let’s grab some designs from our (imaginary) designer friends and give our directive superpowers. Luckily, I prepared some good looking buttons for you here.

Copy the classes of the primary button and add them to our directive.

import {Directive, HostBinding} from "@angular/core";

@Directive({
  selector: '[natButton]',
  standalone: true,
})
export class ButtonDirective {
  @HostBinding('class')
  private twClasses = `inline-flex items-center rounded-md border border-transparent bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2`
}
Enter fullscreen mode Exit fullscreen mode

Refresh your storybook page and see how beautiful our button looks now.

Primary styled button

Making it smart.

While our button looks great, so far we just copied around a bunch of tailwind classes.

Time to make our directive smart and support multiple themes and sizes.

First, let’s split our long twClasses string into smaller groups based on their functionality.

import {Directive, HostBinding} from "@angular/core";

const base = 'inline-flex items-center rounded-md border font-medium shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2'
const size = 'px-4 py-2 text-sm'
const theme = 'border-transparent bg-sky-600 text-white hover:bg-sky-700 focus:ring-sky-500'

@Directive({
  selector: '[natButton]',
  standalone: true,
})
export class ButtonDirective {
  @HostBinding('class')
  private twClasses = `${base} ${size} ${theme}`
}
Enter fullscreen mode Exit fullscreen mode

Much better! Can you guess where we are going with this?
Let’s add an input property to our directive that lets us change the theme!

import {Directive, HostBinding, Input} from "@angular/core";

const base = 'inline-flex items-center rounded-md border font-medium shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2'
const size = 'px-4 py-2 text-sm'
const theme = 'border-transparent bg-sky-600 text-white hover:bg-sky-700 focus:ring-sky-500'
const themeSecondary = 'border-gray-300 bg-white text-gray-700 hover:bg-sky-700 focus:ring-gray-50'

export type Theme = 'primary' | 'secondary';

const buildTwClasses = (currentTheme: Theme): string =>
  `${base} ${size} ${currentTheme === 'secondary' ? themeSecondary : theme}`;

@Directive({
  selector: '[natButton]',
  standalone: true,
})
export class ButtonDirective {
  @HostBinding('class')
  private twClasses = buildTwClasses('primary')

  private _theme: Theme = 'primary';
  @Input()
  set theme(value: Theme) {
    this._theme = value;
    this.twClasses = buildTwClasses(this._theme);
  }
}

Enter fullscreen mode Exit fullscreen mode

What exactly is happening in this code?


  • We added the theme input to our directive which takes in a parameter of type Theme, which is our currently supported themes, primary and secondary.
  • There is a buildTwClasses helper function that builds the tailwind classes for our buttons based on the currentTheme we pass in.
  • Our set function rebuilds the twClasses every time the theme input changes and assigns it to our twClasses property, which is bound to the host's class property.

Let’s manually change our story template to use the secondary theme and see if our code works.

import {ButtonDirective} from "./button.directive";
import {Meta, Story} from "@storybook/angular";

export default {
  title: 'Design System/Atoms/Button',
} as Meta<ButtonDirective>;

export const Default: Story<ButtonDirective> = () => ({
  moduleMetadata: {
    imports: [ButtonDirective],
  },
  template: `<button natButton theme="secondary">Click</button>`
});
Enter fullscreen mode Exit fullscreen mode

Refresh the page to see if the changes are picked up.

Storybook UI showing the secondary button style

They are!! If you got this far you are now equipped with the fundamental knowledge to build beautiful buttons or links that look like buttons or anything you want that looks like buttons.

However, there are a lot of improvements and enhancements we can add. So if I sparked your interest keep on reading!

Making it super smart

So far we have a decent button directive. However, as our design system grows and designers are adding large buttons used in CTA’s of the new version of our app. They also pointed out that there is no visual hint to when a button is disabled. Let’s update our button.directive.ts and make sure we add some styles for that to our button base.

const base = `inline-flex items-center rounded-md border font-medium shadow-sm
  focus:outline-none focus:ring-2 focus:ring-offset-2
  disabled:opacity-50 disabled:focus:ring-0 disabled:active:ring-0`;
Enter fullscreen mode Exit fullscreen mode

Next, we add a new input property called size to our directive.
It accepts inputs of type Size, which is our supported sizes, base and l.
While we are here let’s refactor our Tailwind configurations.

Instead of toggling between string constants, let's create objects that hold the class strings organized by the Theme and Size inputs respectively.

export const themes = ['primary', 'secondary'] as const;
export type Theme = typeof themes[number];
const themeClasses: { [key in Theme]: string } = {
  primary: 'border-transparent bg-sky-600 text-white hover:bg-sky-700 disabled:bg-sky-600 focus:ring-sky-500',
  secondary: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 disabled:bg-white focus:ring-sky-500'
}

export const sizes = ['base', 'l'] as const;
export type Size = typeof sizes[number];
const sizeClasses: { [key in Size]: string } = {
  base: 'px-4 py-2 text-sm',
  l: 'px-5 py-3 text-base'
};
Enter fullscreen mode Exit fullscreen mode

Did you notice that we create those types based on constant string arrays of our supported themes & sizes. As we add support for sizes and themes our types will magically adjust! Super smart!

In our buildTwClasses function we can now use our inputs as keys to look up the respective classes. Using this approach we can easily add new themes or sizes as our design grows.

const buildTwClasses = (currentTheme: Theme, size: Size): string =>
  `${base} ${sizeClasses[size]} ${themeClasses[currentTheme]}`;
Enter fullscreen mode Exit fullscreen mode

Our directive with all its inputs on configuration now looks like this:

import {Directive, HostBinding, Input} from "@angular/core";

const base = `inline-flex items-center rounded-md border font-medium shadow-sm
  focus:outline-none focus:ring-2 focus:ring-offset-2
  disabled:opacity-50 disabled:focus:ring-0 disabled:active:ring-0`;

export const themes = ['primary', 'secondary'] as const;
export type Theme = typeof themes[number];
const themeClasses: { [key in Theme]: string } = {
  primary: 'border-transparent bg-sky-600 text-white hover:bg-sky-700 disabled:bg-sky-600 focus:ring-sky-500',
  secondary: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 disabled:bg-white focus:ring-sky-500'
}

export const sizes = ['base', 'l'] as const;
export type Size = typeof sizes[number];
const sizeClasses: { [key in Size]: string } = {
  base: 'px-4 py-2 text-sm',
  l: 'px-5 py-3 text-base'
};

const buildTwClasses = (currentTheme: Theme, size: Size): string =>
  `${base} ${sizeClasses[size]} ${themeClasses[currentTheme]}`;

@Directive({
  selector: '[natButton]',
  standalone: true,
})
export class ButtonDirective {
  @HostBinding('class')
  private twClasses = buildTwClasses('primary', 'base');

  private _theme: Theme = 'primary';
  @Input()
  set theme(value: Theme) {
    this._theme = value;
    this.twClasses = buildTwClasses(this._theme, this._size);
  }

  private _size: Size = 'base';
  @Input()
  set size(value: Size) {
    this._size = value;
    this.twClasses = buildTwClasses(this._theme, this._size);
  }
}
Enter fullscreen mode Exit fullscreen mode

Again, we can manually update our story, checking if our new size input is picked up correctly.

import {ButtonDirective} from "./button.directive";
import {Meta, Story} from "@storybook/angular";

export default {
  title: 'Design System/Atoms/Button',
} as Meta<ButtonDirective>;

export const Default: Story<ButtonDirective> = () => ({
  moduleMetadata: {
    imports: [ButtonDirective],
  },
  template: `<button natButton theme="secondary" size="l">Click</button>`
});
Enter fullscreen mode Exit fullscreen mode

Perfect! A large button with our secondary theme is displayed!

A large button with our secondary theme is displayed

Leveling up our storytelling.

Right now we are still updating our stories file a lot to preview our changes. Wouldn’t it be great to be able to see all the different button styles update dynamically in our storybook? Let’s make it happen.

In our button.stories.ts file, we create a ButtonDirectiveProps type that will drive our story controls. It extends the ButtonDirective with the content property.

type ButtonDirectiveProps = ButtonDirective & {
  content: string;
};
Enter fullscreen mode Exit fullscreen mode

We then specify that we want a select control for our size & theme inputs and tell storybook to populate their options with the supported sizes & themes respectively.

export default {
  title: 'Design System/Atoms/Button',
  argTypes: {
    size: {
      control: { type: 'select' },
      options: sizes,
    },
    theme: {
      control: { type: 'select' },
      options: themes,
    },
  },
} as Meta<ButtonDirectiveProps>;
Enter fullscreen mode Exit fullscreen mode

Next, we add some default args for our stories. Let’s take the first of each input options and set our content to Angular & Tailwind rock.

const DefaultArgs = {size: sizes[0], theme: themes[0], content: 'Angular & Tailwind rock'};
Enter fullscreen mode Exit fullscreen mode

We then rename our Default story to Template and add the args parameter to the function. Our args will be of type ButtonDirectiveProps, which we extend with our button specific disabled property and then bind to the stories props. We update our template to bind our angular inputs to the new props we provide.
Lastly, we copy the Template as described in the Storybook introduction and set the stories arguments to our defaults, finally setting the disabled property to false.

const Template: Story<ButtonDirectiveProps & {  disabled: boolean; }> = (args) => ({
  props: args,
  moduleMetadata: {
    imports: [ButtonDirective],
  },
  template: `<button natButton [disabled]="disabled" [size]="size" [theme]="theme">{{content}}</button>`
});

export const Default = Template.bind({})
Default.args = {...DefaultArgs, disabled: false};
Enter fullscreen mode Exit fullscreen mode

Let’s reload our stories at localhost:4400.

Storybook view of button directive

Did you see the controls appear and our button updating when we change any of them!

I mentioned that the great thing about directives is that we can apply them to a variety of tags. Let’s see what happens if we add our directive to an anchor tag.

We copy our Template and Default export.

Change the disabled property to one called link and rename our Template to Anchor template and our export to Anchor. Lastly, we wire up our link property with the anchor tags href property and add a link to the stories args.

const AnchorTemplate: Story<ButtonDirectiveProps & {link: string}> = (args) => ({
  props: args,
  moduleMetadata: {
    imports: [ButtonDirective],
  },
  template: `<a target="_blank" [href]="link" natButton [size]="size" [theme]="theme">{{content}}</a>`
});

export const Anchor = AnchorTemplate.bind({})
Anchor.args = {...DefaultArgs, link: 'https://media.giphy.com/media/jJQC2puVZpTMO4vUs0/giphy.gif'};
Enter fullscreen mode Exit fullscreen mode

Reload your storybook one more time and check out the newly added Anchor story. Click on the anchor disguised as our button and you’ll see that it works as expected.

Congratulations! You made it.

You made it to the end of this tutorial and built an awesome button directive. And did notice how clean our stories markup was? No Tailwind classes in the markup.

What’s next?

This is my first time writing an article like this. So first of all, thanks for reading! I hope you learned something new! What’s next? You decide. Did you enjoy this article? Are you interested in learning about the Storybook setup I use for this project? What UI element should we create next? Would you rather have a video tutorial you can follow along with? Let me know in the comments.

Top comments (3)

Collapse
 
luqeckr_30 profile image
luqeckr

how to build the library?
it seem i failed to do:

nx build ui-atoms-button

Collapse
 
goetzrobin profile image
Robin Goetz

Thanks for your comment! The library in the example is actually not created with the buildable flag. This means that by default it will only be built into the app that uses it. You can however create a new buildable library with the nx generator and copy the code over. Then you should be able to run the said command. Let me know if that helps

Collapse
 
luqeckr_30 profile image
luqeckr

hey, thanks.. it's working now

my steps are:

  • create new library using

    nx generate @nrwl/angular:library ui/atoms/natbutton --buildable

  • copy the button directive and story to the new natbutton folder

  • renamed export class and selector name on the old one, so it doesn't conflict

  • remove the standalone option, cause the generated library use module, so I put ButtonDirective in declaration in @NgModule

  • change prefix in project.json and .eslintrc.json to nat, so i can use the name "natButton" in directive selector

  • finally, the command work:

    nx build ui-atoms-natbutton