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.
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 Angular’s new standalone config. If you are using Modules make sure to add your directive to the declarations and exports arrays
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'
}
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>`
});
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 thebutton
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.
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`
}
Refresh your storybook page and see how beautiful our button looks now.
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}`
}
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);
}
}
What exactly is happening in this code?
- We added the
theme
input to our directive which takes in a parameter of typeTheme
, which is our currently supported themes,primary
andsecondary
. - There is a
buildTwClasses
helper function that builds the tailwind classes for our buttons based on thecurrentTheme
we pass in. - Our
set
function rebuilds thetwClasses
every time the theme input changes and assigns it to ourtwClasses
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>`
});
Refresh the page to see if the changes are picked up.
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`;
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'
};
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]}`;
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);
}
}
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>`
});
Perfect! 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;
};
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>;
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'};
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};
Let’s reload our stories at localhost:4400.
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'};
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.
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)
how to build the library?
it seem i failed to do:
nx build ui-atoms-button
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
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