ArticleNavigation
Building your own navigation bar for Teachable
How the menu at the top of this page is built: unlimited links two levels deep, a phone version, and a different menu for signed-in students, all in one custom code block.
Teachable's own navigation is a single row of links. Add a sixth and it folds the rest into a More button. There are no dropdowns and no way to group links, so a school with more than a handful of pages ends up hiding most of them.
The bar at the top of this page has three dropdown menus, more than twenty links, a version for phones, and an account menu that only appears once you've signed in. It's one custom code block, pasted onto each page. This article goes through how it's put together, in the order I'd build it again.
The four parts
Everything lives in one block, in four parts:
| Part | What it does |
|---|---|
| The links | Plain HTML lists. Adding a link is one line. |
| The styles | How it looks, and which menus are open. |
| A short script | Opens and closes menus on click, tap and keyboard. |
| Liquid | Decides, before the page arrives, whether you see "Sign in" or your account. |
The colours come from the shared block that starts every page on this school, so the bar follows light and dark mode and the accent colour without any colours of its own.
Switch off Teachable's header
On each page that uses the custom bar, remove Teachable's own header in the page editor, then add a custom code block at the top of the page and paste the navigation into it. If the page has an announcement bar, that goes above it.
To remove the Teachable header, from the editor view, click on the cog wheel at the top of the page then toggle of the header.
Do this one page at a time and check each one as you go. A page with both headers looks broken, and a page with neither leaves people stuck.
Write the links as plain HTML
Each menu is a button followed by a panel of links. The panel is split into groups, each with a heading. Here's the Courses menu, cut down:
<li class="ph-nav__item">
<button class="ph-nav__trigger" type="button" aria-expanded="false" data-ph-menu>
Courses
</button>
<div class="ph-panel">
<div class="ph-group">
<h3 class="ph-group__heading">Start here</h3>
<div class="ph-group__items">
<a href="/p/teachable-without-limits">Teachable, without the limits</a>
<a href="/courses/3033045/lectures/66897923">Pricing plans explained</a>
</div>
</div>
</div>
</li>
Two details matter here:
- The menu name is a button, not a link. It opens something rather than going somewhere. Buttons work with the keyboard and are announced properly by screen readers, with no extra code.
-
aria-expandedsays whether the menu is open. Screen readers read it out, and the styles use it to show the panel. One attribute does both jobs.
To add a link, copy an <a> line and change it. To add a
group, copy a whole ph-group. There's no limit.
Let the styles do the showing
Every panel starts hidden. When the button before it is marked as open, the panel appears:
.ph-panel {
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity 180ms ease, transform 180ms ease, visibility 180ms;
}
.ph-nav__trigger[aria-expanded="true"] + .ph-panel {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
The + means "the panel straight after this button". Hiding it
with visibility rather than display: none lets it
fade and slide in, and still keeps hidden links out of the way of the
keyboard.
A few lines of script to open and close
Menus that open on hover are easy to build and hard to use. They don't
work on a phone or with a keyboard, and they snap shut when the mouse
drifts off the edge. So these open on click, and the script's whole job is
to flip aria-expanded:
var nav = document.querySelector('.ph-nav');
var triggers = Array.prototype.slice.call(nav.querySelectorAll('[data-ph-menu]'));
function closeAll(except) {
triggers.forEach(function (t) {
if (t !== except) t.setAttribute('aria-expanded', 'false');
});
}
/* Click a menu name: open it, and close any other */
triggers.forEach(function (trigger) {
trigger.addEventListener('click', function (event) {
event.stopPropagation();
var open = trigger.getAttribute('aria-expanded') === 'true';
closeAll(trigger);
trigger.setAttribute('aria-expanded', open ? 'false' : 'true');
});
});
/* Click anywhere else: close everything */
document.addEventListener('click', function (event) {
if (!nav.contains(event.target)) closeAll();
});
/* Escape: close the open menu and go back to its button */
document.addEventListener('keydown', function (event) {
if (event.key !== 'Escape') return;
var open = triggers.filter(function (t) {
return t.getAttribute('aria-expanded') === 'true';
})[0];
if (open) { open.setAttribute('aria-expanded', 'false'); open.focus(); }
});
The account menu uses the same data-ph-menu attribute, so it
gets all of this for free.
Signed in, or signed out
This is the part that has to live on a page. A visitor needs a way to sign in; a student wants their dashboard and account. Liquid, Teachable's template language, knows which one you are:
{% if current_user %}
<div class="ph-account">
<button class="ph-account__trigger" type="button" aria-expanded="false" data-ph-menu>
<img class="ph-avatar" src="{{ current_user.gravatar_url }}" alt="">
Account
</button>
<div class="ph-account__menu">
<a href="/p/dashboard">Dashboard</a>
<a href="/l/products">My courses</a>
<a href="/current_user/profile">Profile</a>
<a href="/sign_out">Sign out</a>
</div>
</div>
{% else %}
<a class="ph-account__trigger" href="/sign_in">Sign in</a>
{% endif %}
You're signed out
So the bar at the top shows a Sign in button. Sign in (a free account is enough), come back to this page, and the button becomes your picture with an Account menu. A Dashboard link appears beside Work with me, too.
This box uses the same check as the bar. Teachable decided which version to send before the page left its servers, so nothing in your browser had to work it out.
Because Liquid runs before the page is sent, there's no flicker: you never see "Sign in" for a moment before it changes to your account. And the other version isn't hidden on the page, it simply isn't there.
This is for convenience, not security. Leaving a link out of the menu doesn't stop anyone typing the address. Anything people pay for belongs in a course, where Teachable controls who gets in.
The phone version
Three dropdowns don't fit across a phone. Below 1000 pixels wide, the row of menus is hidden and a menu button takes its place. It opens a drawer with the same links, where each menu becomes a section that folds open:
.ph-nav__primary { display: none; }
.ph-drawer { display: none; }
.ph-drawer[data-open="true"] { display: block; }
@media (min-width: 1000px) {
.ph-nav__primary { display: block; }
.ph-burger { display: none; }
.ph-drawer { display: none !important; } /* even if it was left open */
}
The drawer has its own copy of the links, which is the one real cost of this approach: add a link and you add it twice. I keep the two lists in the same order in the file, so it's easy to check one against the other.
The Liquid check is repeated in the drawer too, so phones get the same signed-in and signed-out versions.
Two looks from one set of links
On a wide screen there's a switch at the top right of this page, marked "Demo: nav style". Mega shows every group side by side, with a short description under each link. Simple shows a narrow list where each group opens to the side.
Both use exactly the same HTML. The switch changes one attribute on the bar, and every style rule starts with it:
<header class="ph-nav" data-ph-nav="mega">
.ph-nav[data-ph-nav="mega"] .ph-panel__groups {
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
}
.ph-nav[data-ph-nav="simple"] .ph-group__items a span {
display: none; /* no descriptions in the simple version */
}
Your school only needs one of the two. Pick it, set
data-ph-nav to match, and delete the switch and the other
set of rules.
Why it's pasted on every page
Pasting the same block onto every page sounds like the wrong way round. The obvious alternative is to load it once, from the head code snippet, for the whole school. I looked at that and decided against it, for two reasons:
- Liquid doesn't run in code snippets. The signed-in check would have to be done in the browser instead, which is slower and less reliable (more on that below).
- The head snippet runs on every page, lessons included. Keeping it small keeps lessons quick to load.
The price is that changing a link means pasting the block again on each page that uses it. On a school with ten or so pages, that's a few minutes. Keep a list of those pages at the top of the file so none get missed.
Where it can't reach
Some pages on a Teachable school aren't yours to change:
| Page | What shows instead |
|---|---|
| Lessons | Teachable's lesson bar. On this school it's restyled from the head snippet to match. |
The product list (/l/products) | Teachable's own header and footer. |
| Checkout and account pages | Teachable's. They're on a different address, where your code never runs. |
Lessons are the one people notice most, and they're why this is an article rather than a lesson in the free course. Liquid doesn't run inside a lesson, so the signed-in demo above couldn't work there.
Could JavaScript do the signed-in check instead?
Partly. Some of Teachable's pages carry information a script can read to tell whether someone is signed in, but Teachable's newer pages don't. And a script only runs once the page has arrived, so the menu shows the wrong version for a moment before it corrects itself.
For a navigation bar on your own pages, Liquid is simpler and never wrong. JavaScript is the fallback for the places Liquid can't go, like the lesson page.
In short
- One custom code block: plain HTML links, styles, a short script and a little Liquid.
- Menus open on click, using
aria-expanded, so they work with touch and keyboard. - Liquid gives signed-in students a different menu, decided before the page arrives.
- Phones get a drawer with its own copy of the links.
- It's pasted on each page because Liquid only runs in page blocks.