Skip to main content
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Clark Valberg
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Clark Valberg

Scattering Letters Based On Mouse Movements In AngularJS

By
Published in ,

At the bottom of our Design Disruptor movie marketing page, the marketing team (props to Devin Schulz) put in a fun little effect where the scattering of letters, on the page, begins to converge as your mouse moves closer to a sign-up form. As a fun Friday experiment, I wanted to see if I could recreate this kind of scatter effect in AngularJS using a custom directive.

Run this demo in my JavaScript Demos project on GitHub.

By default, HTML elements have static positioning. This means that they appear inline within the flow of the normal document. However, if you give an element "relative" positioning, the element will take up space as though it were inline (ie, not positioned); however, it will be rendered relative to its original location. We can leverage this behavior in our scatter effect by wrapping each individual letter with an element that is positioned relative to its static location:

NOTE: You can also do this using a CSS transform with translateX() and translateY(). Which may or may not provide a different performance characteristic.

Scatting letters using relative position in CSS.

Then, as the user moves the mouse closer to or farther from a "converge target", we can adjust the relative positioning of each individual letter.

While this is an AngularJS directive, you will notice that there are no calls to $apply() or $digest() in this demo. That's because there is no view-model that AngularJS needs to manage. Not everything in AngularJS needs to be managed by the $digest life-cycle. There's nothing wrong with keeping a purely UI (User Interface) interaction completely within the bounds of a directive and not worrying about the $digest life-cycle or the view-model.

NOTE: You'll still need to unbind events during the $destroy event (which I have omitted in my demo since there is no $destroy taking place).

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

	<title>
		Scattering Letters Based On Mouse Movements In AngularJS
	</title>

	<link rel="stylesheet" type="text/css" href="//fonts.googleapis.com/css?family=Open+Sans:300,400,600"></link>
	<link rel="stylesheet" type="text/css" href="./demo.css"><link>
</head>
<body>

	<h1>
		Scattering Letters Based On Mouse Movements In AngularJS
	</h1>

	<div class="container">

		<div
			scatter-effect="div.container"
			scatter-distance="1000"
			class="tagline">
			Happy Friday You <em>Beautiful</em> People!
		</div>

		<input type="text" placeholder="Enter your email..." />

	</div>


	<!-- 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.7.min.js"></script>
	<script type="text/javascript">

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


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


		// I provide a means to scatter the individual letters within the content of
		// the target element relative the mouse's relationship to the given converge
		// target (either the current element or a provided selector).
		// --
		// CAUTION: Expects jQuery to be powering jqLite.
		angular.module( "Demo" ).directive(
			"scatterEffect",
			function scatterEffectDirective( $document ) {

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


				// I compile the template element, preparing it for the scatter effect.
				function compile( tElement, tAttributes ) {

					// In order to make each letter scatter, we have to wrap each one in
					// an individual Span tag that can be positioned relatively.
					wrapLetters( tElement );

					// Return the linking function.
					return( this.link );


					// ---
					// PRIVATE METHODS.
					// ---


					// I find all the text nodes in the given parent (and its descendants)
					// and returns them as a single array.
					function findTextNodes( parent ) {

						// NOTE: Because we are using jQuery under the hood, the .map()
						// function will collapse returned arrays.
						var textNodes = angular.element( parent ).contents().map(
							function operator( i, node ) {

								return( ( node.nodeType === 1 ) ? findTextNodes( node ) : node );

							}
						);

						return( textNodes.toArray() );

					}


					// I find and wrap each text-based letter, within the given parent,
					// in its own Span tag.
					function wrapLetters( parent ) {

						findTextNodes( parent ).forEach(
							function( node ) {

								// Replace each individual letter with a Span tag.
								var wrappedHtml = node.nodeValue.replace( /([^\s])/g, "<span class='scatter-item'>$1</span>" );

								var fragment = angular.element( document.createDocumentFragment() )
									.append( wrappedHtml )
								;

								node.parentNode.insertBefore( fragment[ 0 ], node );
								node.parentNode.removeChild( node );

							}
						);

					}

				}


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

					// If the converge target *selector* was provided, use it. Otherwise,
					// use the current element as the converge target.
					var convergeTarget = attributes.scatterEffect
						? angular.element( attributes.scatterEffect )
						: element
					;

					// Determine how far away their static position that letters can scatter.
					var maxScatterDistance = ( parseInt( attributes.scatterDistance, 10 ) || 500 );

					// Create our collection of letter configurations.
					var letters = element.find( "span.scatter-item" )
						.map(
							function operator( i, node ) {

								return({
									target: angular.element( node ),
									maxX: generateRandomOffset(),
									maxY: generateRandomOffset()
								});

							}
						)
						.toArray()
					;

					// Start listening for the mouse move to adjust the layout of the
					// letters as the user moves closer to or farther from the converge
					// target.
					$document.on( "mousemove", handleMousemove )


					// ---
					// PRIVATE METHODS.
					// ---


					// I generate a random offset in the range of -maxScatterDistance : maxScatterDistance.
					function generateRandomOffset() {

						// We are generating the random number multiples times to help
						// make the scattering seem more random. The JavaScript random()
						// function has a tendency to seem grouped.
						for ( var i = 0 ; i < 50 ; i++ ) {

							var value = ( maxScatterDistance - ( Math.random() * maxScatterDistance * 2 ) )

						}

						return( value );

					}


					// I get the distance from the given {x,y} coordinate to the given
					// converge target. This uses the outer perimeter of the converge
					// target as the means of measurement.
					function getDistanceFromConverge( convergeTarget, x, y ) {

						var offset = convergeTarget.offset();
						var width = convergeTarget.outerWidth();
						var height = convergeTarget.outerHeight();

						// Calculate the position of the four-sides of the target.
						var top = offset.top;
						var left = offset.left;
						var right = ( left + width );
						var bottom = ( top + height );

						// Calculate the horizontal leg (of the triangle).
						if ( x < left ) {

							var deltaX = ( left - x );

						} else if ( x > right ) {

							var deltaX = ( x - right );

						} else {

							var deltaX = 0;

						}

						// Calculate the vertical leg (of the triangle).
						if ( y < top ) {

							var deltaY = ( top - y );

						} else if ( y > bottom ) {

							var deltaY = ( y - bottom );

						} else {

							var deltaY = 0;

						}

						// Calculate the distance to the target using Pythagoras' theorem.
						// --
						// CAUTION: A^2 + B^2 = C^2 ... like a boss.
						return( Math.sqrt( ( deltaX * deltaX ) + ( deltaY * deltaY ) ) );

					}


					// I handle the mouse movement event.
					function handleMousemove( event ) {

						// Determine how far the mouse is from the converge target.
						var distance = getDistanceFromConverge( convergeTarget, event.pageX, event.pageY );

						// Update the layout of the letters to be proportional with the
						// distance to the converge target.
						positionScatterItems( distance );

					}


					// I position the scatter items based on the given distance of the
					// user's mouse from the converge target.
					function positionScatterItems( distance ) {

						// NOTE: Using Math.min() because we only want to start
						// calculating percentage when the current distance is within
						// the bounds of max scatter distance.
						var percentage = Math.min( 1, ( distance / maxScatterDistance ) );

						letters.forEach(
							function( letter ) {

								letter.target.css({
									left: Math.floor( letter.maxX * percentage ),
									top: Math.floor( letter.maxY * percentage )
								});

							}
						);

					}

				}

			}
		);

	</script>

</body>
</html>

The most challenging part of this was wrapping each letter in a Span tag. If you know the structure of your target element, that's easy. But, I had to account for nested element tags, which involved recursion and carefully replacing content fragments (as opposed to just overwriting an .html() value). Anyway, this was just a fun Friday experiment. Happy Friday!

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

Reader Comments

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