Skip to main content
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Adam Presley
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Adam Presley ( @adampresley )

Using Anchor Tags And URL-Fragment Links In AngularJS

By on

Yesterday, Derek Knox mentioned on Twitter that he was having trouble getting anchor links to work in an AngularJS application. And, by anchor links, I mean the ability for a user to click on an <A> tag and be taken to another location on the same page (typically identified with a name="" or id="" attribute). Naively, I said it should "just work." But, I was mistaken - anchor tags are an interesting little beast in an AngularJS context

Run this demo in my JavaScript Demos project on GitHub.

After some digging, I realize that I wasn't 100% wrong in my "just work" statement. But, I was mostly wrong. It turns out, link behavior is only modified once you force the $location service to be initialized. So, if you are building a simple one-view demo, like I do most of the time for this blog, anchor tags do "just work." But, the moment you inject the $location service, things get complicated.

If you look at the AngularJS source code, the first thing you might notice is that, internally, the $location.$$parseLinkUrl() method only gets called if the originating anchor tag doesn't have a "target" attribute. So, you might be tempted to solve this problem by adding target="_self" (explicitly defining the default browser behavior) to your anchor tags:

<a href="#chapter-one" target="_self">Jump to Chapter One</a>

This sort of half-works. But not even really. The URL still moves into an unexpected state, adding a "/" before the anchor value: #/chapter-one. And, if you're using ngRoute, this will likely conflict with your .otherwise() configuration which handles unknown route requests. Plus, we have to alter the way our <A> tags are configured, which feels less than amazing, especially considering the fact that the URL still doesn't represent the state of the actual page.

The root of the problem relates to the fact that in a single-page AngularJS application, the "application URL" is really just the fragment of the browser's top-level location. As such, to get an anchor link to work in AngularJS, we really want it to be the fragment portion of the application fragment.

Yo dawg, I heard you like fragments. So, I put a fragment in your fragment so you can link while you route in AngularJS.

So now, the way I see it, we have a few choices - we can either fight really hard to keep the href="#location" format working in an AngularJS application; or, we can change our anchor tags and get seamless use of anchors in AngularJS.

To start with, let's look at the uphill battle approach. We can provide decent support for URL-fragment-based anchors if we capture the click event on said anchors and wire the anchor value into the $location.hash(). In this way, we can leverage the existing $location and $anchorScroll() behavior of AngularJS.

To do this, we can create an "a" directive that compiles and links anchor tags. In the following code, since an HREF is quite flexible, I'm making the assumption that we only want to execute the linking phase if the given HREF value starts with a static "#" value.

<!doctype html>
<html ng-app="Demo">
<head>
	<meta charset="utf-8" />

	<title>
		Using Anchor Tags And URL-Fragment Links In AngularJS
	</title>
</head>
<body ng-controller="AppController as vm">

	<h1>
		Using Anchor Tags And URL-Fragment Links In AngularJS
	</h1>

	<h2>
		Compiling The &lt;A&gt; Element
	</h2>

	<p>
		<!-- Normal "route" links. -->
		<strong>Pages</strong>:
		<a href="#/section-a">Section A</a> &nbsp;|&nbsp;
		<a href="#/section-b">Section B</a> &nbsp;|&nbsp;
		<a href="#/section-c">Section C</a> &nbsp;|&nbsp;
		<a href="#/section-d">Section D</a>
	</p>

	<p>
		<strong>Current Url</strong>: {{ vm.currentUrl }}
	</p>

	<p>
		<!-- A "fragment" anchor link. -->
		<a href="#footer">Jump to footer</a>.
	</p>

	<p style="height: 2000px ;">
		<!-- To force scrolling. -->.
	</p>

	<p id="footer">
		This is a footer!
	</p>


	<!-- Load scripts. -->
	<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.2.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs/angular-route-1.4.2.min.js"></script>
	<script type="text/javascript">

		// Create an application module for our demo.
		angular.module( "Demo", [ "ngRoute" ] );


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I configure the application routes to make sure that the user cannot go out
		// side of the supported route definitions.
		angular.module( "Demo" ).config(
			function configureRoutes( $routeProvider ) {

				$routeProvider
					.when( "/section-a", {} )
					.when( "/section-b", {} )
					.when( "/section-c", {} )
					.when( "/section-d", {} )
					.otherwise({
						redirectTo: "/section-a"
					})
				;

			}
		);


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I control the root of the application.
		angular.module( "Demo" ).controller(
			"AppController",
			function AppController( $scope, $location, $route ) {

				var vm = this;

				vm.currentUrl = "";

				// When the location changes, capture the state of the full URL.
				$scope.$on(
					"$locationChangeSuccess",
					function locationChanged() {

						vm.currentUrl = $location.url();

					}
				);

			}
		);


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I compile the <A> tag, allowing normal URL-fragment anchor links to work
		// "as intended" within the context of an AngularJS application that uses the
		// $location service, which alters the behavior of link-clicks.
		angular.module( "Demo" ).directive(
			"a",
			function urlFragmentDirective( $location, $anchorScroll ) {

				// Return the directive configuration object.
				return({
					compile: compile,
					restrict: "E"
				});


				// I compile the anchor tag, returning a linking function if necessary.
				function compile( tElement, tAttributes ) {

					// If the anchor tag has a target attribute, skip this instance of
					// the tag since Angular will already allow the natural link behavior
					// (of a targeted link) to take place.
					if ( tAttributes.target ) {

						return;

					}

					var href = ( tAttributes.href || tAttributes.ngHref || "" );

					// If the anchor tag doesn't have an HREF, skip this instance - we
					// know that we won't have anything to link to upon click.
					// --
					// NOTE: This assumes that the HREF for URl-fragment style anchor
					// will be present in the original HTML and won't be injected during
					// the compilation phase of another directive.
					if ( ! href ) {

						return;

					}

					// If the HREF value doesn't start with a URL-fragment hash, skip it.
					// --
					// NOTE: This assumes that URL-fragment marker (ie, the "#") won't be
					// injected based on attribute interpolation. Honestly, I think this
					// is a pretty fair assumption based on the nature of URL-fragments
					// style links.
					if ( href.charAt( 0 ) !== "#" ) {

						return;

					}

					// If the anchor tag appears to represent a "Route", ignore it - let
					// the natural routing behavior take effect.
					if ( href.charAt( 1 ) === "/" ) {

						return;

					}

					return( link );

				}


				// I bind the JavaScript events to the view-model.
				function link( scope, element, attributes ) {

					element.on(
						"click",
						function handleClickEvent( event ) {

							// If this was a "special" click, ignore it (the $location
							// service will also ignore it).
							if (
								event.ctrlKey ||
								event.metaKey ||
								event.shiftKey ||
								( event.which == 2 ) ||
								( event.button == 2 )
								) {

								return;

							}

							var href = element.attr( "href" );

							// If attribute interpolation caused this HREF to become a
							// route, then ignore the click event and let the natural
							// routing behavior take effect.
							if ( href.indexOf( "#/" ) === 0 ) {

								return;

							}

							// At this point, we know we want to intercept the link
							// behavior so that AngularJS doesn't try to manage it for us
							// (which is where the problem of URL-fragment links is coming
							// from). As such, cancel the default behavior.
							event.preventDefault();

							var fragment = href.slice( 1 );

							// If the fragment is already part of the URL, then we have
							// to explicitly tell Angular to perform the scroll to the
							// target anchor. Since this click won't actually change the
							// location state, the $anchorScroll won't execute.
							if ( fragment === $location.hash() ) {

								return( $anchorScroll() );

							}

							// Now that we know we need to manage the state of the URL,
							// let's pipe the URL-fragment into the $location() where it
							// becomes the fragment ON THE FRAGMENT that represents the
							// current route. When doing this, the $anchorScroll() service
							// will automatically pick up the change and auto-scroll the
							// page to the appropriate hash.
							$location.hash( fragment );

							// Since the $location is part of the view-model, we have to
							// tell AngularJS that the state has changed.
							scope.$apply();

						}
					);

				}

			}
		);

	</script>

</body>
</html>

As you can see, this directive is ultimately binding a click-handler on the anchor tag where it can intercept the click event in order to prevent the default behavior. By preventing the default behavior, we stop AngularJS from trying to rewrite the URL, which leaves it up to use to alter the location. And, we do so by piping the HREF value into the $location.hash() property where the $anchorScroll() service will automatically scroll the page to the target element.

This mostly works - you can't right-click and copy the anchor location - but it feels a bit weird to have to link up every anchor instance just to manage the few URL-fragment links that we might have in the application. As such, we can try moving the logic up into an event-delegation layer where one click-binding can manage the entire application.

If you look at the AngularJS internals, AngularJS binds a click-event handler on the $rootElement. This means that in order to intercept the click-event and cancel the default behavior, we have to initiate the event delegation on a descendant element. And, since most AngularJS apps use the HTML tag as the $rootElement, we can set up a directive that links on the BODY tag.

<!doctype html>
<html ng-app="Demo">
<head>
	<meta charset="utf-8" />

	<title>
		Using Anchor Tags And URL-Fragment Links In AngularJS
	</title>
</head>
<body ng-controller="AppController as vm">

	<h1>
		Using Anchor Tags And URL-Fragment Links In AngularJS
	</h1>

	<h2>
		Linking The &lt;Body&gt; Element
	</h2>

	<p>
		<!-- Normal "route" links. -->
		<strong>Pages</strong>:
		<a href="#/section-a">Section A</a> &nbsp;|&nbsp;
		<a href="#/section-b">Section B</a> &nbsp;|&nbsp;
		<a href="#/section-c">Section C</a> &nbsp;|&nbsp;
		<a href="#/section-d">Section D</a>
	</p>

	<p>
		<strong>Current Url</strong>: {{ vm.currentUrl }}
	</p>

	<p>
		<!-- A "fragment" anchor link. -->
		<a href="#footer">Jump to footer</a>.
	</p>

	<p style="height: 2000px ;">
		<!-- To force scrolling. -->.
	</p>

	<p id="footer">
		This is a footer!
	</p>


	<!-- Load scripts. -->
	<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.2.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs/angular-route-1.4.2.min.js"></script>
	<script type="text/javascript">

		// Create an application module for our demo.
		angular.module( "Demo", [ "ngRoute" ] );


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I configure the application routes to make sure that the user cannot go out
		// side of the supported route definitions.
		angular.module( "Demo" ).config(
			function configureRoutes( $routeProvider ) {

				$routeProvider
					.when( "/section-a", {} )
					.when( "/section-b", {} )
					.when( "/section-c", {} )
					.when( "/section-d", {} )
					.otherwise({
						redirectTo: "/section-a"
					})
				;

			}
		);


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I control the root of the application.
		angular.module( "Demo" ).controller(
			"AppController",
			function AppController( $scope, $location, $route ) {

				var vm = this;

				vm.currentUrl = "";

				// When the location changes, capture the state of the full URL.
				$scope.$on(
					"$locationChangeSuccess",
					function locationChanged() {

						vm.currentUrl = $location.url();

					}
				);

			}
		);


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I setup an interceptor on <BODY> tag, allowing normal URL-fragment anchor
		// links to work "as intended" within the context of an AngularJS application
		// that uses the $location service, which alters the behavior of link-clicks.
		// --
		// CAUTION: Since the event-delegation logic is a bit more complicated in this
		// approach, I am including the jQuery logic so I can set up event-delegation on
		// the BODY tag an easily search for the $rootElement.
		angular.module( "Demo" ).directive(
			"body",
			function urlFragmentDirective( $rootElement, $location, $anchorScroll, $log ) {

				// Return the directive configuration object.
				return({
					link: link,
					restrict: "E"
				});


				// I bind the JavaScript events to the view-model.
				function link( scope, element, attribute ) {

					// For this approach to work, we need to ensure that the BODY tag is
					// a descendant of the $rootElement. This is a critical point because
					// AngularJS uses event-delegation on the $rootElement to rewire <a>
					// link click behaviors. As such, we can only [be guaranteed to]
					// intercept the click-event if the BODY tag can see it first, before
					// it bubbles up to the $rootElement.
					if ( element.is( $rootElement ) || ! element.closest( $rootElement ).length ) {

						return( $log.warn( "URL-fragment interceptor cannot be configured on the Body as it is not a descendant of the $rootElement." ) );

					}

					// At this point, we know that we are in a position to intercept
					// link-click events. As such, let's set up event-delegation to listen
					// for <a> clicks.
					element.on(
						"click",
						"a",
						function handleClickEvent( event ) {

							// If this was a "special" click, ignore it (the $location
							// service will also ignore it).
							if (
								event.ctrlKey ||
								event.metaKey ||
								event.shiftKey ||
								( event.which == 2 ) ||
								( event.button == 2 )
								) {

								return;

							}

							// Since we are using jQuery's event-delegation, we know that
							// THIS refers to the <A> tag that triggered the event.
							var target = angular.element( this );

							// If the anchor tag has a target attribute, ignore the event
							// since Angular will already allow the natural link behavior
							// (of a targeted link) to take place.
							if ( target.attr( "target" ) ) {

								return;

							}

							var href = ( target.attr( "href" ) || "" );

							// If the relative HREF doesn't start with a URL-fragment
							// indicator, ignore this event.
							if ( href.charAt( 0 ) !== "#" ) {

								return;

							}

							// If the relative HREF appears to be a route, ignore this
							// event; let the natural routing behavior take place.
							if ( href.charAt( 1 ) === "/" ) {

								return;

							}

							// At this point, we know we want to intercept the link
							// behavior so that AngularJS doesn't try to manage it for us
							// (which is where the problem of URL-fragment links is coming
							// from). As such, cancel the default behavior.
							event.preventDefault();

							var fragment = href.slice( 1 );

							// If the fragment is already part of the URL, then we have
							// to explicitly tell Angular to perform the scroll to the
							// target anchor. Since this click won't actually change the
							// location state, the $anchorScroll won't execute.
							if ( fragment === $location.hash() ) {

								return( $anchorScroll() );

							}

							// Now that we know we need to manage the state of the URL,
							// let's pipe the URL-fragment into the $location() where it
							// becomes the fragment ON THE FRAGMENT that represents the
							// current route. When doing this, the $anchorScroll() service
							// will automatically pick up the change and auto-scroll the
							// page to the appropriate hash.
							$location.hash( fragment );

							// Since the $location is part of the view-model, we have to
							// tell AngularJS that the state has changed.
							scope.$apply();

						}
					);

				}

			}
		);

	</script>

</body>
</html>

Now, event-delegation cuts down on the amount of processing that the $compile() service has to perform as well as the number of event bindings that get configured; but, it doesn't really solve the problem in a new way. In fact, we still suffer from not being able to right-click and copy the link value. This is because, ultimately, the HREF value doesn't reflect the intent of the click - it's the click-handlers that bridge the gap.

In the first two approaches, we are jumping through a lot of hoops because we are trying to keep the href="#location" format in tact. But, if we are open to changing the way our anchor tags work, we can get a really simple, feature-complete solution. If, instead of trying to shoehorn URL-fragments into the HREF attribute, we move them into a new ngAnchor directive, we can get the most robust solution.

In this final approach, URL-fragment links use an ngAnchor directive and route-oriented links use the standard HREF attribute. The key to making this approach successful is that we actually alter the HREF value, in realtime, in response to the state of the application. This allows our nested fragments to be reflected in the actual link which means that, unlike the first two approaches, we can actually right-click and copy the link.

<!doctype html>
<html ng-app="Demo">
<head>
	<meta charset="utf-8" />

	<title>
		Using Anchor Tags And URL-Fragment Links In AngularJS
	</title>
</head>
<body ng-controller="AppController as vm">

	<h1>
		Using Anchor Tags And URL-Fragment Links In AngularJS
	</h1>

	<h2>
		Using An ngAnchor Directive
	</h2>

	<p>
		<!-- Normal "route" links. -->
		<strong>Pages</strong>:
		<a href="#/section-a">Section A</a> &nbsp;|&nbsp;
		<a href="#/section-b">Section B</a> &nbsp;|&nbsp;
		<a href="#/section-c">Section C</a> &nbsp;|&nbsp;
		<a href="#/section-d">Section D</a>
	</p>

	<p>
		<strong>Current Url</strong>: {{ vm.currentUrl }}
	</p>

	<p>
		<!-- A "fragment" anchor link using ngAnchor instead of HREF. -->
		<a ng-anchor="#footer">Jump to footer</a>.
	</p>

	<p style="height: 2000px ;">
		<!-- To force scrolling. -->.
	</p>

	<p id="footer">
		This is a footer!
	</p>


	<!-- Load scripts. -->
	<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.2.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs/angular-route-1.4.2.min.js"></script>
	<script type="text/javascript">

		// Create an application module for our demo.
		angular.module( "Demo", [ "ngRoute" ] );


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I configure the application routes to make sure that the user cannot go out
		// side of the supported route definitions.
		angular.module( "Demo" ).config(
			function configureRoutes( $routeProvider ) {

				$routeProvider
					.when( "/section-a", {} )
					.when( "/section-b", {} )
					.when( "/section-c", {} )
					.when( "/section-d", {} )
					.otherwise({
						redirectTo: "/section-a"
					})
				;

			}
		);


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I control the root of the application.
		angular.module( "Demo" ).controller(
			"AppController",
			function AppController( $scope, $location, $route ) {

				var vm = this;

				vm.currentUrl = "";

				// When the location changes, capture the state of the full URL.
				$scope.$on(
					"$locationChangeSuccess",
					function locationChanged() {

						vm.currentUrl = $location.url();

					}
				);

			}
		);


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I watch the ngAnchor attribute to automatically configure the HREF value to
		// use natural URL-fragment behavior in AngularJS.
		// --
		// NOTE: We don't actually need the $anchorScroll() service; but, we need to
		// inject it here in order to guarantee that it is instantiated and starts
		// watching the $location for changes.
		angular.module( "Demo" ).directive(
			"ngAnchor",
			function anchorDirective( $location, $anchorScroll ) {

				// Return the directive configuration object.
				return({
					link: link,
					restrict: "A"
				});


				// I bind the JavaScript events to the view-model.
				function link( scope, element, attributes ) {

					// Whenever the attribute changes, we have to update our HREF state
					// to incorporate the new ngAnchor value as the embedded fragment.
					attributes.$observe( "ngAnchor", configureHref );

					// Whenever the the location changes, we want to update the HREF value
					// of this link to incorporate the current URL plus the URL-fragment
					// that we are watching in the ngAnchor attribute.
					scope.$on( "$locationChangeSuccess", configureHref );


					// I update the HREF attribute to incorporate both the current top-
					// level fragment plus our in-page URL-fragment intent.
					function configureHref() {

						var fragment = ( attributes.ngAnchor || "" );

						// Strip off the leading # to make the string concatenation
						// handle variable-state inputs (ie, ones that may or may not
						// include the leading pound sign).
						if ( fragment.charAt( 0 ) === "#" ) {

							fragment = fragment.slice( 1 );

						}

						// Since the anchor is really the fragment INSIDE the fragment,
						// we have to build two levels of fragment.
						var routeValue = ( "#" + $location.url().split( "#" ).shift() );
						var fragmentValue = ( "#" + fragment );

						attributes.$set( "href", ( routeValue + fragmentValue ) );

					}

				}

			}
		);

	</script>

</body>
</html>

To me, this last approach - using a custom URL-fragment directive - is the only one that I would really consider taking in an actual production AngularJS application. The first two approaches can be layered on top of an existing setup without any changes whatsoever; but, this comes at the cost of complexity and an incomplete feature-set. The ngAnchor approach clearly defines two different kinds of links and provides a feature-complete URL-fragment behavior, all with much less complexity.

Want to use code from this post? Check out the license.

Reader Comments

1 Comments

Hi Ben,
Thank you very much for this.

I am using a variation of your final solution to rewrite the hrefs. There is one problem where if the hash is already present in the url, then selecting that anchor again will not scroll the page. For example, using your demo example, if the URL is this:
http://bennadel.github.io/JavaScript-Demos/demos/route-anchor-angularjs/anchor.htm#/section-c#footer
Then I scroll the page back up and click Footer again, the page does not scroll again to the footer id.

Do you have a solution for that?

(Also note: in my app, I am using reloadOnSearch: false with my particular route in order to make it not refresh the page.)
Thanks for your help.

1 Comments

Hello Ben,
Thank you for your 3rd solution, it is the best I found so far to use anchors in Angular!

There is just one problem, and I don't know how to solve it:

When we copy a link with an anchor and run it, the scroll only works from within the application. I mean when we click such "anchored" link from another website, it doesn't land on the designated element but at the top of the page.
The whole point of having these links is partially lost at this point.

1 Comments

I fixed this by using this hint at stackoverflow:

http://stackoverflow.com/questions/20268213/can-i-reclaim-control-of-the-url-hash-fragment-from-angularjs

Basically, when you define your app you can configure it to ignore the hash on fragments so it doesn't mess with it.

app.config(function($locationProvider) {
$locationProvider.html5Mode(true).hashPrefix('!');
});

Not sure if this is the same problem you had but my links to fragments now just work.

Brian

15,688 Comments

@Brian,

Very interesting. I'm actually not all that familiar with the html5Mode - I've been using fragments since day-one. And since I primarily work on brown-field apps, I haven't had practical experience with html5. Thanks for the insight on this fix.

15,688 Comments

@Ary,

Oh that's interesting :( I am surprised that it doesn't work; but, I haven't tested that. I'll see if I can dig into it.

15,688 Comments

@L,

Oooh, really good question. I don't have a good answer for that. I'd have to dig into the internals of the source to see how / when it implements the scroll in the first place.

1 Comments

@Ben,

Hi Ben,

I have a issue with read more button.After displaying 500 characters read more button should display.it is working fine when i post text.
it is not working in case,text contains url's.(it directly displaying read more button,without text)
<p ng-bind-html="Text|limitTo:500"></p>.
please solve this

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel