Skip to content

IntroJS

Created: 2017-06-20 08:59:29 -0700 Modified: 2020-03-22 15:49:36 -0700

I did not like this library, and I would almost certainly never use it for a future project. It caused a lot of pain due to how many times I needed to dive into Intro.JS’s code to rework it. The whole thing was a single file, and a poorly coded one at that, so it wasn’t ever fun to do.

Main site: http://introjs.com/

GitHub: https://github.com/usablica/intro.js

My forked version: https://github.com/Adam13531/intro.js

Note: I wrote most of this back when I was working with version 2.5.0. Since then, there have been many changes.

6/20/2017

At a high level, there are steps and hints.

  • Steps are different parts of a tutorial that can be navigated between with back/next (and note that these can be hidden of course).
  • Hints are little beacons that show on elements that you can click to see some more information.

Steps and hints can be defined via “data-” attributes in the DOM directly or programmatically via addStep/addSteps/addHints.

At a code level:

When you make an IntroJS, you can pass in an optional element (an Object, a string, or nothing). If it’s nothing, then IntroJS will be created using the document’s body.

This will set up “this._options”, which is just an object of basic booleans, strings, etc. for configuration. There are no DOM elements in there.

this._options.steps starts out as undefined.

The element in the application that IntroJS “highlights” gets .introjs-showElement added, which puts a super-high z-index with “!important”.

Then, behind it is a div that IntroJS adds with .introjs-helperLayer with a z-index one lower to put a white background behind the element that it’s highlighting. The helperLayer is repositioned in “_refresh”, which is called when showing, hiding, or resizing the window.

To programmatically add steps, you need to call addStep or addSteps; it can’t happen during construction. Doing so changes this._options.steps.

At this time, this._options.steps will only contain plain old JavaScript objects (i.e. no DOM elements).

When you call into start(), _introForElement is called with the “target” element (i.e. document.body if you didn’t pass in an element to the constructor).

If this._options.steps are not defined, then it tries pulling that information from the DOM. However, in my case, they’re defined, so I’ll cover what happens then: the options you passed in are cloned and the element is hydrated from the DOM using document.querySelector. If the element doesn’t exist, then the element with the class “.introjsFloatingElement” will be created (if necessary) and then used.

The cloned options (which will now have the DOM element) get populated into this._introItems.

After this._introItems is set up with DOM elements, _nextStep is called to actually start the tutorial.

_nextStep advances this._currentStep through some decently complex logic given how _goToStepNumber works (they set a temporary variable (this._currentStepNumber) to a number, then _nextStep checks for that, and if it’s set, it uses it and undefines it).

At this point, this._currentStep will refer to the number of the current step, and this._currentStepNumber should always be undefined.

Finally, we call _showElement on an element of _introItems.

Note: throughout the above, certain callbacks get hit at various times (e.g. before changing, before exiting, etc.).

_showElement: remember that this takes in an element of _introItems, meaning it has all of the options you specified for an individual step and also a DOM element corresponding to the query selector that you passed in for “element”. This function is gigantic.

  1. It calls _introChangeCallback if it’s defined.
  2. It applies a highlight class (if necessary) from your overall IntroJS and from the individual step itself.
  3. If the “.introjs-helperLayer” doesn’t exist, it will create one. This involves creating basically everything: the bullets, the progress bar, the step numbers, the navigation buttons, etc. If any of those are disabled via options from the caller then they’re just hidden via CSS in the library.
  4. If the “.introjs-helperLayer” did exist, then it just sets the right classes, positions, etc.
  5. Regardless of whether the layer existed or not, the element can be scrolled into view if specified to do so.
  6. Finally, _introAfterChangeCallback is called if it’s defined.

There’s no way to differentiate between Back/skip/done with just CSS. In fact, there’s a way to get the library to theme the “done” and “back” buttons exactly the same way. I wanted to make a modification to change this behavior.

Turns out this change was introduced by PikachuEXE here; they copied the wrong class onto the skipTooltipButton.

skipTooltipButton is either given skipLabel or doneLabel according to what step we’re on. It’s given the skipLabel on the first step of the tour if the tour has more than one step. When it hits the end, it’s given the doneLabel and then never changed back to “skip”.

Some changes I made that I’ll probably forget at some point

Section titled Some changes I made that I’ll probably forget at some point

Remember that the skip and done buttons are actually the same button, and it shows on the left by default:

I didn’t like having a “Done” and a “Next” button, so when you specify showDoneButtonOnRight=true, you’re moving the Done button to the right side and hiding the Next button:

For reference, here’s the default functionality:

I ran into a problem where people were accidentally clicking “Skip” (because they expected it to be the “Next” button or something), so I wanted to make sure that a user intended to skip a tutorial by popping up a dialog first. The problem is that as soon as you press “skip” in IntroJS, it calls its callbacks and then exits.

This function is pretty straightforward; it’s called if it exists, and the original code to exit IntroJS is passed to the function as a callback.

(shown above: introjs-showElement is not actually on the element at the bottom, so its z-index is lower than IntroJS’s helper element)

What’s happening is that I am calling something like this internally:

setElementAtBottomIsVisible(true)

showNextTutorialStep()

The issue is that setElementAtBottomIsVisible only sets a prop in Redux, which will trigger a re-render for the element, but at the time that IntroJS sees it, it has an extra CSS class that adds “visibility: hidden”. IntroJS puts its introjs-showElement class on what’s in the DOM at the time, then React updates the DOM and wipes out the introjs-showElement class.

To fix this, I have observeClassMutations. This lets IntroJS add a MutationObserver (reference) to the element to be shown. When the class changes, it will add introjs-showElement.

This is similar to bypassCaching; the element actually does change, so what’s cached is the element, but it changes based on something that IntroJS doesn’t detect (which in this case is attribute modifcations).

I coded this for a very specific scenario: in Bot Land, the Blueprint Selector has a “Create Blueprint” button. At the time of writing, on desktop, it shows inside of a scrollable panel, but on mobile, it always shows with position: absolute. That means that when changing media queries, we need to scroll to the element.

This option can be specified so that _scrollParentToElement is called whenever the browser resizes. Without this, you may see something like this:

(shown above: we’re highlighting where the element should be if only we’d scrolled to it)

Disallowing “next” when pressing right arrow in some cases

Section titled Disallowing “next” when pressing right arrow in some cases

I have this code as of 8/27/2018 :

if (!_isOnLastStep.call(this) || _isSkipButtonVisible()) {
_nextStep.call(this);
}

The reason I did this is because I didn’t want people to mash the right arrow repeatedly to close IntroJS when the dialog said something like “Now press the Save button to complete the tutorial”.

Summary: this causes the “done” button to act like skip when specified.

When to use in Bot Land: whenever there’s a final step in a series of steps but not in the tutorial, if IntroJS has the user do anything other than click “Got it”, then I need to specify this as true.

In revamping the tutorial, I want to have the ability to skip an entire tutorial while it’s running. However, IntroJS uses the same button for both “done” and “skip”. It sets doneLabel when on the final step of a set of steps, and skipLabel at all other times. In Bot Land though, the final step is not necessarily the end of a tutorial; a BL tutorial is a set of sets of steps. Thus, I still want the button to say “skip” even if it’s the final step sometimes. In those cases, I can specify “preventTreatingSkipAsDone” on a step just like I would “element” or “intro”, and the step will always act like it has a “Skip” button.

I added “customIconProperties” so that I could put a settings gear at the upper right of the tutorial menu. Turns out people didn’t like that, but it’s still nice to be able to have a custom element in the tutorial somewhere, so here’s how you do that:

Specify this in intro.js-react’s “options”:

customIconProperties: {
cssClass: styles.tutorialSettingsIcon,
innerHtml: '<i class="material-icons">settings</i>',
onClick: () => this.props.setSettingsIsShowing(true),
},

Here’s the LESS CSS:

.tutorialSettingsIcon {
cursor: pointer;
position: absolute;
right: -14px;
text-shadow: 0px 0px 3px white, 0px 0px 3px white;
top: -17px;
& > i {
font-size: 2.5em;
}
}

I can’t get IntroJS to select the element that I want

Section titled I can’t get IntroJS to select the element that I want

IntroJS just uses document.querySelector underneath, so this is likely a mistake on your part writing the selector. To come up with the right selector, just use document.querySelector directly from the browser’s DevTools so that you can figure out which query works.

An element is being highlighted with the wrong z-index, so the user can’t proceed

Section titled An element is being highlighted with the wrong z-index, so the user can’t proceed

(shown above: we’re trying to highlight a button, but instead, it looks like nothing is highlighted. Note that the white bar at the bottom is something from the operating system, not from IntroJS)

The goal was to get it looking like this:

The solution ended up being this CSS (note that this is done via CSS modules, so ":global " won’t be needed in regular CSS):

body :global(.introjs-fixParent) {
// We need to disable momentum-based scrolling when Intro.js is highlighting
// an absolute/relative positioned element.
-webkit-overflow-scrolling: auto !important;
}

An element is almost unreadable when it’s highlighted

Section titled An element is almost unreadable when it’s highlighted

See this picture:

This is simply a problem of not having a background color on the element to be highlighted, so you should just add one:

Alternatively, you can put “highlightClass” directly into the step to give IntroJS the background color. This ends up looking okay:

Top: IntroJS being told to highlight an element with no background-color

Bottom: a highlightClass added whose CSS is: background-color: #153968 !important;

An element is being highlighted with the wrong dimensions

Section titled An element is being highlighted with the wrong dimensions

An element is being highlighted at the upper left corner

Section titled An element is being highlighted at the upper left corner

(note: it’s supposed to highlight the section at the bottom right of the screen, but it’s highlighting seemingly nothing at the upper left)

This was an incredibly tricky bug to track down. IntroJS caches elements as soon as the steps start, but React may re-render such elements, causing the DOM references to be different from what IntroJS has. That means that IntroJS will add/remove the “introjs-showElement” CSS class to a DOM element that no longer exists and never will be in the DOM again. In these cases, we want to avoid caching so that we can still point to the correct elements on the screen.

I addressed it by adding a set of options to IntroJS: a global option, bypassCachingByDefault, and a per-step option,

bypassCaching. For my tutorial, I specify bypassCachingByDefault, that way I should never run into this issue again. Note that this fix also addresses the case where the IntroJS helper element shows up in front of the target element rather than behind it (it does so by adding .introjs-showElement again after re-fetching).

Note that I only did this for steps, not hints.

Because I specify bypassCachingByDefault in Bot Land, if I ever see this, then it’s due to a second cause: you’re trying to highlight an element that has “display: none”. The fix is to highlight a different element or not have “display: none” of course. 😏

All other element-is-being-highlighted-with-wrong-dimensions problems

Section titled All other element-is-being-highlighted-with-wrong-dimensions problems

Shown above: the white rounded rectangle on the bottom is supposed to highlight the entire dark blue dialog.

The problem here is that the component is not in the DOM when Intro tries to highlight it.

Solution #1: render the component with “visibility:hidden ” instead of not rendering anything at all

Solution #2: modify the component’s “componentDidUpdate” to check when its visibility changes, and when it does, alert the parent. I can then use this alert to set the proper tutorial step.

Solution #3: set an interval that keeps checking for the component to exist in the DOM and then update the tutorial state accordingly

Solution #4: modify IntroJS so that it keeps checking its target element’s boundaries

Solution #1 is by far the easiest as long as the React component was already having its “render()” function called.

Solution #2 is probably the proper React way of doing things if you can’t do solution #1 or have too much of a performance impact by using solution #1.

Solution #3 is hacky. It’s probably what solution #4 would be based on though since the element isn’t in the DOM, so it’s not like you can add an event listener.

The “done” button isn’t the style that you think it should be

Section titled The “done” button isn’t the style that you think it should be

It’s basically this bug https://github.com/Adam13531/BotLand/issues/270

(the “Got it” button should be yellow)

This is because I didn’t apply my own “showDoneButtonStyle” that gives it the yellow color.

Tooltip positioning incorrect for animated elements

Section titled Tooltip positioning incorrect for animated elements

(shown: the hardware inventory slides in from the right, but IntroJS is rendering behind it because the inventory hasn’t finished sliding in by the time IntroJS is told to render there)

I thoroughly investigated this, came up with a solution, decided it wasn’t worth it, and figured I’d just document everything here. The reason that it isn’t worth it has to do with my specific scenario: I have something like this:

<EditBlueprintContent>

<CosmeticsInventory/>

<CosmeticsLoadout/>

<Bot/>

<HardwareLoadout/>

<HardwareInventory/>

</EditBlueprintContent>

The EditBlueprintContent is what actually animates, NOT the HardwareInventory that we want to show IntroJS over. That meant that I would have to add a CSS “transitionend” event listener to EditBlueprintContent, then reposition the tooltip when that happens.

I accomplished this with the following test code:

// This gets added to _showElement.

var test = document.querySelector(‘.editBlueprintContent---2Kf’);

if (test != null) {

DOMEvent.on(test, ‘transitionend’, _onResize, this, false);

}

The problems are:

  • It isn’t parameterized; I’m hard-coding the specific class name
  • It always adds the event listener every time showElement is hit
  • It never removes the event listener
  • The event listener would need to be added every time React makes a new element. IntroJS caches elements that React may no longer refer to, so this problem is very tough to solve. I wrote my own “bypassCaching”, but presumably I would want to bypass caching for some arbitrary parent element.

There are two other reasonable solutions here:

  • Detect transitionend from React instead of IntroJS and use that information to advance the tutorial
  • Work around the problem by only centering IntroJS on the screen rather than showing it for an animated element

I ended up going with detecting transitionend on my own. This wasn’t too bad, but the payoff is so minor that I was questioning whether it was really worth saving/documenting. Here’s what I had to do:

  1. Modify componentDidMount of EditBlueprint to add a transitionend listener
  2. Modify componentWillUnmount to remove that listener
  3. When the listener is triggered, call callTutorialFunctionIfExists(‘blueprintEditorPanelFinishedTransition’), that way just the tutorials that care about this can operate.
  4. The tutorial steps that I wanted to have care about this actually had a difference between mobile / desktop. On mobile, there are no transitions, so I can’t rely on just this function. Thus, I needed a mix of selectedBlueprintEditorPanel and blueprintEditorPanelFinishedTransition.

An element is being relocated by IntroJS

Section titled An element is being relocated by IntroJS

For reference, here’s what this should look like:

Here’s what it does look like:

The problem is that IntroJS adds “.introjs-relativePosition” to the element even though it should have an absolute position.

This particular problem only shows up in Bot Land in a very specific case where these conditions are all met:

  1. You’re transitioning between two media queries (e.g. Desktop → Mobile) where the source media query has no “position” property, but the destination media query has “position: absolute”.
  2. The element to be highlighted is the same on both media queries (i.e. you’re not selecting “.button1” on desktop and “.button2” on mobile).

Solution #1 (this is what I ended up going with): just add a “position: relative” to the source media query’s CSS class. In my case, I just had to modify .newsAttackButton so that it had “position: relative”.

Solution #2: split the desktop/mobile steps so that they’re based on media queries. This forces the transformation system into play which will reinitialize the element’s highlight.

This is a bad solution because it will only fix the problem for one tutorial (even though it may appear in multiple tutorials) and because it’s more lines of code.