Build custom directives with AngularJS

Most everything we use in AngularJS is a directive. Directives are what makes AngularJS so powerful and responsive. Directives are the root of AngularJS and how we as developers interact with AngularJS.

Although AngularJS is packed with powerful directives out of the box, often times we want to create our own reusable functionality. In this post, we’ll focus on how to tackle the seemingly complex process of creating directives. We’ll start with building simple directives and explain the process throughout the post.

To start making your own directive, let’s first understand what directives actually are. Directives, in AngularJS are, at their core functions that get run when the DOM is compiled by the compiler.

Using this powerful concept, AngularJS enables us to create new directives that we can encapsulate and simplify DOM manipulation. We can create directives that modify or even create totally new behavior in HTML.

If you’ve ever used any part of AngularJS before, you’ve used a directive, whether you know it or not. The ng-app attribute is a directive, so is ng-controller and all of the ng- prefixed attributes.

1
2
3
4
<body ng-app> <input type="text" ng-model="name" placeholder="name"> <h1>{{ name }}</h1> </body>

Try it

Type ‘Hello Ari Lerner’

{{ name }}

When AngularJS loads this simple example, it will traverse the DOM elements looking for any directives that are associated with each DOM element. Once it’s found all of them (if there are multiple directives), it will then start running the directive and associating it with the DOM element. This all happens behind the scenes and automatically for you.

To invoke a directive from HTML, we simply can apply the directive in the DOM. That is to say, we pick one of the four methods for invoking a directive:

As an attribute:

1
<span my-directive></span>

As a class:

1
<span class="my-directive: expression;"></span>

As an element:

1
<my-directive></my-directive>

As a comment:

1
<!-- directive: my-directive expression -->

These are the same in the eyes of AngularJS. In fact, AngularJS even gives you other options for invoking directives with name prefixes. These are all equivalent, as well:

1
2
3
4
5
6
<input type="text" ng-model="directivename" placeholder="name" /> <span ng-bind="directivename"></span> <span ng:bind="directivename"></span> <span ng_bind="directivename"></span> <span x-ng-bind="directivename"></span> <span data-ng-bind="directivename"></span>

Try it

ng-bind=“directivename”:
ng:bind=“directivename”:
ng_bind=“directivename”:
x-ng-bind=“directivename”:
data-ng-bind=“directivename”:

The last two are are the only methods of invoking directives that are HTML5 compliant and that will pass HTML5 validators.

Building our first directive

Although we’ll discuss in greater detail how directives actually work at the fundamental level later, let’s start by creating our first directive.

We’ll be walking through creating a sparkline directive that will show the weather forecast for the next few days based on openweathermap data. We will be building this directive:

Our first, basic directive looks like this:

1
2
3
4
5
6
app.directive('ngSparkline', function() { return { restrict: 'A', template: '<div class="sparkline"></div>' } });

And then we’ll invoke it in our html:

1
<div ng-sparkline></div>

Notice that when we invoke it, the name of the directive is not the same as when we define it (ngSparkline vs. ng-sparkline). This is because AngularJS will handle translating the camel cased name when we define it to the snake case when we invoke it.

Although our first example doesn’t do very much (yet), it already is showing some powerful features. Anywhere in our html, we can add the attribute ng-sparkline and have the template be appended to the DOM element.

There are two ways to build a directive. In our example, we’re using the method of returning a directive description object. AngularJS expects either one of these objects or a link function when we’re creating a directive. Building a directive with the link function is usually enough for relatively simple directives. For the most part, we’ll be creating directives using the description object.

Restrict option

In our example’s description object, we’re setting two config components. First, we’re setting the restrict config option. The restrict option is used to specify how a directive can be invoked on the page.

As we saw before, there are four different ways to invoke a directive, so there are four valid options for restrict:

1
2
3
4
'A' - <span ng-sparkline></span> 'E' - <ng-sparkline></ng-sparkline> 'C' - <span class="ng-sparkline"></span> 'M' - <!-- directive: ng-sparkline -->

The restrict option can specify multiple options, as well. If you want to support more than one as an element or an attribute, simply make sure all are specified in the restrict keyword:

1
restrict: 'EA'

Template

Secondly, in our example we’re also setting a template. This template is an inline template where we are specifying the html that will be appended (or replaced, we’ll discuss this shortly). This is particularly useful when we want to share directives across apps and you only want to pass a single file around.

TemplateUrl

If you prefer to load a template over ajax, you can specify the templateUrl option, which will use ajax to pull the template.

1
templateUrl: 'templates/ng-sparkline-template.html'

With that, we can start adding functionality. Before we can jump straight into that, we need to look at how the directive is instantiated in the DOM.

How directives are born (compilation and instantiation)

When the DOM is done loading and the AngularJS process starts booting up, the first process that happens is the HTML is parsed by the browser as a DOM tree. This tree is then parsed using AngularJS’s $compile() method. $compile runs through the DOM tree and looks for directive declarations for the different DOM elements. Once all directive declarations are found for each DOM element and sorted (by priority, which we’ll get into shortly), the directive’s compile function is run and is expected to return a link() function. The $compile() function will return a linking function that wraps all of the containing DOM element’s directives' linking functions.

Finally, the linking function is invoked with the containing scope that attaches all of the associated directives to that scope. This is where we’ll do most of the work when building directives, as this is where we can register listeners, set up watches, and add functionality. The result of this process is why the live data-binding exists between the scope and the DOM tree.

Why have a compile and link function?

So why does AngularJS have two functions that run at the compile phase instead of just combining them into one? Boiled down, the answer is for performance. It’s slow to compile and interpolate against scopes every time a new directive of the same type is created. Because of this, all of the slow parts of the compilation process are front-loaded into the compile phase, while the linking happens when everything is associated and attached to the DOM.

In summary

We’ll use the compile function to both manipulate the DOM before it’s rendered and return a link function (that will handle the linking for us). This also is the place to put any methods that need to be shared around with all of the instances of this directive.

We’ll use the link function to register all listeners on a specific DOM element (that’s cloned from the template) and set up our bindings to the page.

Back to ngSparkline

Our sparkline graph will need to do a little more than show us a div on the page to actually be useful. To do that, we’ll have to bind a controller input on the directive. Basically, we’ll want the directive to be driven by the input of another directive. In most cases, we’ll want to bind our directive to the ng-model directive’s controller.

Require option

We’ll enforce that we need this dependency by setting the require option:

1
2
3
4
5
6
7
app.directive('ngSparkline', function() { return { restrict: 'A', require: '^ngModel', template: '<div class="sparkline"><h4>Weather for {{ngModel}}</h4></div>' } });

Now, if we invoke the directive on the page without the ng-model directive, our browser will complain and throw an error. To invoke our directive now, we simply have to add the ng-model directive:

1
2
<input type="text" ng-model="city" placeholder="Enter a city" /> <div ng-sparkline ng-model="city"></div>

Notice, in the require option, we prefixed the controller with a ^. AngularJS gives you two options in the require option about how to declare requirements with a prefixed character:

1
2
^ -- Look for the controller on parent elements, not just on the local scope ? -- Don't raise an error if the controller isn't found
Scope

Just like in every other part of AngularJS DOM control, directives can be given their own scopes. This is important to note, because without declaring an isolated scope from the rest of the DOM, our directive could muck with the local controller scope and cause unexpected behavior.

To get around this trouble, AngularJS gives you the ability to isolate the scope of the directive from the rest of the page using the scope option.

1
2
3
4
5
6
7
8
9
10
app.directive('ngSparkline', function() { return { restrict: 'A', require: '^ngModel', scope: { ngModel: '=' }, template: '<div class="sparkline"><h4>Weather for {{ngModel}}</h4></div>' } });

The scope option can take one of two different options. It can be set to true (it’s false by default).

1
scope: true,

When the scope directive is set to true, a new scope will be created for the directive.

While it’s useful to ensure that a new scope will be created for the directive, that scope will still participate in the normal controller-scope hierarchical relationship. We can isolate the directive’s scope outside of the parent relationship by creating an isolate scope.

An isolate scope does not prototypically inherit from the parent scope, but creates an entirely new one. Creating this isolate scope will ensure that your directive does not mess with the existing scope.

To create an isolate scope, simply pass an object back in the scope option:

1
scope: {}

This will create an empty scope. This is not particularly useful because your directive will have nothing available on its scope (other than the variables that you manually add).

To make local variables on your local scope available to the new directive’s scope, you’ll have to pass one of the following three aliases in the object:

Local scope property

Binding a local scope (string) to the value of the DOM attribute, use the @ symbol. Now the value of the outer scope will be available inside your directive’s scope:

1
@ (or @attr)
Bi-directional binding

A bi-directional binding can be set up between the local scope property and the parent property using the = symbol. If the parent model changes, just like in normal data-binding then the local property will reflect the change.

1
= (or =attr)
Parent execution binding

To execute a function in the context of the parent scope, we can bind a function using the & symbol. This is to say that when setting the value, a function wrapper will be created and point to the parent function.

To call the parent method with an argument, you’ll need to pass an object with the key being the name of the argument and the value being the argument to pass:

1
& (or &attr)

For example, if we’re writing an email client and we are creating an email textbox such as:

1
2
3
<input type="text" ng-model="to" /> <!-- Invoke the directive --> <div scope-example ng-model="to" on-send="sendMail(email)" from-name="[email protected]" />

We have a model (ng-model), a function (sendMail()), and a string (from-name). To get access to these on your directive’s scope:

1
2
3
4
5
scope: { ngModel: '=', // Bind the ngModel to the object given onSend: '&', // Pass a reference to the method fromName: '@' // Store the string associated by fromName }

In this example, we can see the ngModel update when we manipulate the object through bi-directional data-binding. We can see the fromName string appear in our directive and we can see the onSend() method being called when we click on the Send button:

Try it

Show controller source

1
2
3
4
5
6
app.controller('scopeExampleController', ['$scope', function($scope) { $scope.status = "Not sent"; $scope.sendMail = function(mail) { $scope.status = "Sent"; }; }]);

Going back to our sparkline directive, we’ll need to pass in a city with our directive from which we want to pull the weather from openweathermap. To do this, we’ll start out by setting it statically on the directive by passing it in as an attribute:

1
<div ng-sparkline ng-model="San Francisco"></div>

Our directive now looks like this:

1
2
3
4
5
6
7
8
9
10
app.directive('ngSparkline', function() { return { restrict: 'A', require: '^ngModel', scope: { ngCity: '@' }, template: '<div class="sparkline"><h4>Weather for {{ngModel}}</h4></div>' } });

If we want to be more explicit and descriptive about what model we are requiring, we can change the require: '^ngModel' above to be require: '^ngCity'. This comes in handy when you are communicating requirements on a team or you want to reuse the directive in another project. The implementation would change to the more explicit version:

1
<div ng-sparkline ng-city="San Francisco"></div>

If we want to support this method of setting ng-city instead of ngModel, we’ll have to add some supporting logic. As we said above, the require option will inject the controller of the require option.

For ngCity to work, we’ll have to create a custom directive with a controller defined. This is as simple as:

1
2
3
4
5
app.directive('ngCity', function() { return { controller: function($scope) {} } });

Alternatively, you can continue to use ngModel.

Pulling weather data

Now that we have the city, we can use openweathermap to grab the latest weather data.

In order to do this, we’ll have to set up a function that will run when the directive is linked to the DOM. If we write this function in the compile method, then we’ll modify the DOM in place.

1
2
3
4
5
6
7
8
9
10
11
12
13
app.directive('ngSparkline', function() { return { restrict: 'A', require: '^ngCity', scope: { ngCity: '@' }, template: '<div class="sparkline"><h4>Weather for {{ngCity}}</h4></div>', link: function(scope, iElement, iAttrs) { // get weather details } } });

The link function will be run as soon as the directive is linked to the DOM. In order to call out to a separate service, however, we’ll have to inject the $http service. Because of this, we’ll need to define a controller to get access to the service.

Controller option

In a directive, when we set the controller option we are creating a controller for the directive. This controller is instantiated before the pre-linking phase.

The pre-linking and post-linking phases are executed by the compiler. The pre-link function is executed before the child elements are linked, while the post-link function is executed after. It is only safe to do DOM transformations after the post-link function.

We are defining a controller function in our directive, so we don’t need to define either of these functions, but it is important to note that we cannot do DOM manipulations in our controller function.

What does a controller function look like? Just like any other controller. We’ll inject the $http service in our controller (using the bracket injection notation):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.directive('ngSparkline', function() { return { restrict: 'A', require: '^ngCity', scope: { ngCity: '@' }, template: '<div class="sparkline"><h4>Weather for {{ngCity}}</h4></div>', controller: ['$scope', '$http', function($scope, $http) { $scope.getTemp = function(city) {} }], link: function(scope, iElement, iAttrs, ctrl) { scope.getTemp(iAttrs.ngCity); } } });

Note that in our link function, we have access to all of the attributes that were declared in the DOM element. This will become important in a minute when we go to customize the directive.

Now we can create the function getTemp to fetch from the openweathermap.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var url = "http://api.openweathermap.org/data/2.5/forecast/daily?mode=json&units=imperial&cnt=7&callback=JSON_CALLBACK&q=" $scope.getTemp = function(city) { $http({ method: 'JSONP', url: url + city }).success(function(data) { var weather = []; angular.forEach(data.list, function(value){ weather.push(value); }); $scope.weather = weather; }); }

Now, inside of our link function, we’ll have a promise that will be fulfilled by the controller method. A promise, if you’re not familiar, is basically an object that will return the result of an action that is run asynchronously.

1
<div ng-sparkline ng-city="San Francisco"></div>

See it

This will print out a bunch of JSON

As you can see, the directive is rendered on screen initially without data, but as soon as the link method is run (linking the specific element to the DOM), the getTemp method will run and will eventually populate the weather property on the scope.

Now, let’s take this data and create a sparkline with it. To start, we’ll want to pick a property on the weather to work on.

Let’s start by creating a chart on the high temperatures. To start with, we’ll need to create a $watch on the weather object. Because we are fetching the weather data asynchronously, we cannot simply write the method expecting the data to be populated for us when the linking function runs. No matter, AngularJS makes this incredibly easy with the built-in function $watch.

The $watch function will register a callback to be executed whenever the result of the expression changes. Inside the $digest loop, every time AngularJS detects a change, it will call this function. This has the side effect that we cannot depend on state inside this function. To counter this, we’ll check for the value before we depend on it being in place.

Here is our new $watch function:

1
2
3
4
5
6
scope.getTemp(iAttrs.ngCity); scope.$watch('weather', function(newVal) { if (newVal) { } });

Every time the weather property on our scope changes, our function will fire. We will use d3 to chart our sparkline.

Here’s what we have so far:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
app.directive('ngSparkline', function() { var url = "http://api.openweathermap.org/data/2.5/forecast/daily?mode=json&units=imperial&cnt=14&callback=JSON_CALLBACK&q="; return { restrict: 'A', require: '^ngCity', scope: { ngCity: '@' }, template: '<div class="sparkline"><h4>Weather for {{ngCity}}</h4><div class="graph"></div></div>', controller: ['$scope', '$http', function($scope, $http) { $scope.getTemp = function(city) { $http({ method: 'JSONP', url: url + city }).success(function(data) { var weather = []; angular.forEach(data.list, function(value){ weather.push(value); }); $scope.weather = weather; }); } }], link: function(scope, iElement, iAttrs, ctrl) { scope.getTemp(iAttrs.ngCity); scope.$watch('weather', function(newVal) { // the `$watch` function will fire even if the // weather property is undefined, so we'll // check for it if (newVal) { var highs = [], width = 200, height = 80; angular.forEach(scope.weather, function(value){ highs.push(value.temp.max); }); // chart } }); } } });

See it

Show d3 source

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
var highs = []; angular.forEach(scope.weather, function(value){ highs.push(value.temp.max); }); var chartGraph = function(element, data, opts) { var width = opts.width || 200, height = opts.height || 80, padding = opts.padding || 30; // chart var svg = d3.select(element[0]) .append('svg:svg') .attr('width', width) .attr('height', height) .attr('class', 'sparkline') .append('g') .attr('transform', 'translate('+padding+', '+padding+')'); svg.selectAll('*').remove(); var maxY = d3.max(data), x = d3.scale.linear() .domain([0, data.length]) .range([0, width]), y = d3.scale.linear() .domain([0, maxY]) .range([height, 0]), yAxis = d3.svg.axis().scale(y) .orient('left') .ticks(5); svg.append('g') .attr('class', 'axis') .call(yAxis); var line = d3.svg.line() .interpolate('linear') .x(function(d,i){return x(i);}) .y(function(d,i){return y(d);}), path = svg.append('svg:path') .data([data]) .attr('d', line) .attr('fill', 'none') .attr('stroke-width', '1'); }

Now that we have our chart being drawn, let’s look at ways that we can customize the directive when we invoke it.

Inside of our watch function, we’re currently setting a static height and width. We can do better than that by allowing the invocation to determine the width and the height. Because we have access to the iAttrs (the instance of the attributes on the instance of the DOM element), we can simply look there first; otherwise we can set a default.

Change the width and the height to look like this:

1
2
var width = iAttrs.width || 200, height = iAttrs.height || 80;

When we invoke the directive, this time we can add a width to set the width:

1
<div ng-sparkline width='400'></div>

Now our sparkline changes width to be 400 pixels, instead of the default 200.

See it

Inside of our directive as it stands today, we have a label that tells us “Weather for {{ ngCity }}.” Although this is convenient for demo purposes, we might not always want that label to be static inside the directive. We can set it to include any html that we put inside of the DOM element that contains our directive.

Transclude option

Although the name sounds complex, transclusion refers to compiling the content of the element and making the source available to the directive. The transcluded function is pre-bound to the calling scope, so it has access to the current calling scope.

Looking at an example, let’s change the invocation to:

1
2
3
<div ng-sparkline ng-city="San Francisco" width='400'> <h3>A custom view of the weather in San Francisco</h3> </div>

To get this to show up in our template, we’ll need to use a special directive called ngTransclude. This is how the template knows where to place the custom HTML. Let’s modify the template in our directive to include the custom content:

1
template: '<div class="sparkline"><div ng-transclude></div><div class="graph"></div></div>'

Additionally, we’ll have to tell AngularJS that our directive will be using transclusion. To do that, we’ll have to set the transclude option either to:

Set the transclude option to true, as we only want the content of our directive to show up in our template:

1
transclude: true

See it

A custom view of the weather in San Francisco

Replace option

Sometimes it is better to replace the entire DOM object, rather than append the new template to it. AngularJS makes this easy to accomplish by simply adding replace option in the directive description object. Set the replace option to true, like so:

1
replace: true

Priority option

Directives on elements are compiled in a sorted order based on priority. Sometimes it matters what ordered directives are applied. By setting a higher priority, you can almost guarantee the order in which the directives are applied. To set a higher priority of one directive over another, simply add the priority option and set it to a numerical value higher than 0 (the default):

1
priority: 10

Terminal option

Sometimes it’s useful to stop the execution of the compiler for including other directives. This is most useful when used in conjunction with setting the priority. terminal will stop the execution of any directives at a lower priority than this one.

1
terminal: true

Next steps

We’ve covered how to build a directive from the very low-level to the top. Hopefully you’ve gained some confidence and knowledge about how to move forward in the future and provide and build your own custom directives.

Show full source

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
app.directive('ngSparkline', function() { var url = "http://api.openweathermap.org/data/2.5/forecast/daily?mode=json&units=imperial&cnt=14&callback=JSON_CALLBACK&q="; return { restrict: 'A', require: '^ngCity', transclude: true, scope: { ngCity: '@' }, template: '<div class="sparkline"><div ng-transclude></div><div class="graph"></div></div>', controller: ['$scope', '$http', function($scope, $http) { $scope.getTemp = function(city) { $http({ method: 'JSONP', url: url + city }).success(function(data) { var weather = []; angular.forEach(data.list, function(value){ weather.push(value); }); $scope.weather = weather; }); } }], link: function(scope, iElement, iAttrs, ctrl) { scope.getTemp(iAttrs.ngCity); scope.$watch('weather', function(newVal) { // the `$watch` function will fire even if the // weather property is undefined, so we'll // check for it if (newVal) { var highs = []; angular.forEach(scope.weather, function(value){ highs.push(value.temp.max); }); chartGraph(iElement, highs, iAttrs); } }); } } }); var chartGraph = function(element, data, opts) { var width = opts.width || 200, height = opts.height || 80, padding = opts.padding || 30; // chart var svg = d3.select(element[0]) .append('svg:svg') .attr('width', width) .attr('height', height) .attr('class', 'sparkline') .append('g') .attr('transform', 'translate('+padding+', '+padding+')'); svg.selectAll('*').remove(); var maxY = d3.max(data), x = d3.scale.linear() .domain([0, data.length]) .range([0, width]), y = d3.scale.linear() .domain([0, maxY]) .range([height, 0]), yAxis = d3.svg.axis().scale(y) .orient('left') .ticks(5); svg.append('g') .attr('class', 'axis') .call(yAxis); var line = d3.svg.line() .interpolate('linear') .x(function(d,i){return x(i);}) .y(function(d,i){return y(d);}), path = svg.append('svg:path') .data([data]) .attr('d', line) .attr('fill', 'none') .attr('stroke-width', '1'); } app.directive('ngCity', function() { return { controller: function($scope) {} } });

Feel free to sign up for the newsletter for updates when new posts come out as well as for new content on AngularJS weekly.

Get the weekly email all focused on AngularJS. Sign up below to receive the weekly email and exclusive content.
We will never send you spam and it's a cinch to unsubscribe.

Download a free sample of the ng-book: The Complete Book on AngularJS

ng-book: The Complete Book on AngularJS is the canonical AngularJS book available today.

It's free, so just enter your email address and the PDF will be sent directly to your inbox. Mailchimp can take up to an hour to deliver the free sample chapter, but if you don't receive it within the hour, send us an email and we'll manually send them to you!

We'll send you updates about the book, when it updates and other free content.

We will never send you spam and it's a cinch to unsubscribe.

Comments

comments powered by Disqus