Fixed headers can be a great way of keeping an important element, like a menu visible on your webpage.
Sticky headers are even better, in my opinion, because they don’t have to start life fixed to the top. They can start wherever you like.
And headers that disappear as you scroll feel like pure magic! Probably because I just discovered how to create them (and wrote about it in my last blog post).
But I discovered a problem with sticky (and fixed) elements. They don’t work on iOS once the browser opens the keyboard.
Imagine you are building a text editor, with a fixed toolbar at the top. So you can do things like adding a table, making text bold, adding a list etc. But when using the text editor on an iPhone or iPad, the keyboard opens and the toolbar just drifts off the screen.
Something like this...
See the Pen iOS keyboard vs fixed header (the problem) by Codemzy (@codemzy) on CodePen.
This is the nightmare situation I found myself in today. And this is how I fixed it.
Because the codepen is inside an iframe, you might not be able to see the issue consistently in the pen above unless you fork it and view it in debug mode on your device.
The problem
iOS devices (like iPhones and iPads) don't usually have a separate keyboard attached. Instead, they use what's often referred to as a 'soft' keyboard. When you click into an input or editable field, a keyboard display pops up on your screen.
The soft keyboard is a fixed element. As you scroll down the screen, it will stay in the same position at the bottom of the page. Which is great if you need to type. But it takes priority over any other fixed or sticky elements on your page.
So if your sticky (or fixed) element is positioned at the top of the page, let’s say your CSS looks something like this:
.toolbar {
width: 100%;
height: 60px;
position: sticky;
top: 0px;
}
Once that keyboard pops out on the screen, the top of the page is pushed up and out of view. And you can wave goodbye to your sticky (or fixed position) toolbar.
And that might be what you want if your sticky element isn't essential to the input or editable area. But if it is a toolbar for the input (like a WYSIWYG editor, for example), then it is essential! And if it goes off the screen every time you click in the editable area, it's no use to the user.
So to keep our toolbar in view, we don’t want it at the top of the page anymore. We want it at the top of the viewable area.
There are a few things to keep in mind here:
- The height of the keyboard is unknown and varies on each device.
- You don’t get any event triggered when the keyboard pops out.
- There’s no easy way to know when the keyboard is open (or closed).
Not helpful!
I considered turning the entire page into absolute positioned elements. But it seemed overkill to change the entire page structure for an issue that affected only one element and only on certain devices.
While there's no way to stop the keyboard's native behaviour, you can fix this issue. By finding out where the toolbar is, you can reposition it at the top of the visible screen. Sounds like a job for JavaScript!
The fix
We will start by creating a wrapper around the toolbar that's position: sticky
, instead of having the toolbar itself as position: sticky
. This will allow us to have our toolbar as position: absolute
which will help us fix our iOS keyboard issue!
Now the HTML will look like this:
<div id="toolbar-wrap">
<div id="toolbar">
<!-- your toolbar content... -->
</div>
</div>
And the CSS is like this:
#toolbar-wrap {
position: sticky;
top: 0px;
height: 60px;
width: 100%;
}
#toolbar {
position: absolute;
left: 0px;
right: 0px;
padding: 15px;
background-color: #f1f5f9;
}
From the user's point of view, everything looks the same.
Now we need some JavaScript so we can check when the toolbar goes off-screen, and push it back on the screen.
First, I thought about checking the toolbar position on the focus event. And that does work great, but unfortunately, if the user then scrolls with the keyboard open, we lose the toolbar again.
When the keyboard opens, the screen scrolls anyway, so let's cut out the middleman and listen directly to the scroll event.
Here's the basic structure of the JavaScript code.
let fixPosition = 0; // the fix
let lastScrollY = window.pageYOffset; // the last scroll position
let toolbarWrap = document.getElementById('toolbar-wrap'); // the toolbar wrap
let toolbar = document.getElementById('toolbar'); // the toolbar
let editor = document.getElementById('editor'); // the editor
// function to run on scroll
const showToolbar = function() {
// will check if toolbar needs to be fixed
};
// add an event listener to scroll to check if
// toolbar position has moved off the page
window.addEventListener("scroll", showToolbar);
The code above listens to scroll events and runs the function showToolbar
. Currently, that function doesn't do anything, that's what we are about to work on.
Since toolbarWrap
is sticky at the top of the page, its top position should always be 0
. If the position drifts from 0
, then there is a problem.
So here's the fix, and it's pretty simple. If the toolbarWrap
is off the screen, I'm going to add a margin to my toolbar
so that it shows up on the screen.
// if toolbar wrap is hidden
const newPosition = toolbarWrap.getBoundingClientRect().top;
if (newPosition < -1) {
// add a margin to show the toolbar
fixPosition = Math.abs(newPosition); // this is new position we need to fix the toolbar in the display
// set the margin to the new fixed position
toolbar.style["margin-top"] = fixPosition + "px";
}
You might have noticed I actually check if it drifts from -1
with if (newPosition < -1) {
. That's because sometimes the browser reports -0.5
during normal use and I want to allow for that.
In case you are wondering, Math.abs
turns that negative number into a positive number so that the toolbar gets pushed down and not up!
But if you just run the code above, your toolbar will flicker like crazy when the user scrolls - this will not be a good experience. Instead, you can use a debounce
function like the one provided by lodash to avoid setting the margin too often.
So I'm going to move the code into a new debounced function and call that from my function (max every 150ms).
// function to set the margin to show the toolbar if hidden
const setMargin = function() {
// if toolbar wrap is hidden
const newPosition = toolbarWrap.getBoundingClientRect().top;
if (newPosition < -1) {
// add a margin to show the toolbar
fixPosition = Math.abs(newPosition); // this is new position we need to fix the toolbar in the display
// set the margin to the new fixed position
toolbar.style["margin-top"] = fixPosition + "px";
}
}
// use lodash debounce to stop flicker
const debounceMargin = _.debounce(setMargin, 150);
// function to run on scroll
const showToolbar = function() {
// will check if toolbar needs to be fixed
debounceMargin();
};
Nice! Now we are getting somewhere. The setMargin
function adds a margin to our toolbar
, just like we wanted. But when scrolling down, we run it through debounceMargin
so we don't get that flicker. This does mean that the toolbar pops back on screen, rather than staying in place, but I feel it's a good compromise to get rid of the flicker.
There are a few problems that still need attention:
1. The toolbar is in the wrong position if the user manually closes the soft keyboard.
Argh! When the keyboard closes it doesn't trigger the scroll event.
You can fix this by listening to the "blur" event on the editor
element to check the toolbar position again.
editor.addEventListener("blur", showToolbar);
2. As you scroll up the toolbar can drift because the debounce doesn't run right away so the toolbar is slow to react.
This one's a major problem. Our debounce function works great when scrolling down with the keyboard open, but scrolling up we see that toolbar lagging behind and it doesn't look good.
You can fix this by putting the toolbar back in its default position if it is in a fixPosition
first, this takes the toolbar back off the screen (if it needs to be) when scrolling up, and it will appear again once you are stationary.
// function to run on scroll and blur
const showToolbar = function() {
// put toolbar back in default position
if (fixPosition > 0) {
fixPosition = 0;
toolbar.style["margin-top"] = 0 + "px";
}
// will check if toolbar needs to be fixed
debounceMargin();
};
3. When you scroll to the bottom of the page a small gap appears above the sticky/fixed element.
I don't know why this happens, but it's easy to fix. If the user is at the bottom of the page I take 2 pixels off the fixed position. It's hacky, but it works!
fixPosition = Math.abs(newPosition); // this is new position we need to fix the toolbar in the display
// if at the bottom of the page take a couple of pixels off due to gap
if ((window.innerHeight + window.pageYOffset) >= document.body.offsetHeight) {
fixPosition -= 2;
}
// set the margin to the new fixed position
toolbar.style["margin-top"] = fixPosition + "px";
4. It's not very smooth
I wanted to give the toolbar more of an entrance, so I added an extra CSS class down
. I add the class before I set the new position in setMargin
.
if (newPosition < -1) {
// add a margin to show the toolbar
toolbar.classList.add("down"); // add class so toolbar can be animated
// ...
And I remove it when I reset the margin (because I don't want the toolbar to be animated as it disappears, only when it appears).
// function to run on scroll and blur
const showToolbar = function() {
// remove animation and put toolbar back in default position
if (fixPosition > 0) {
toolbar.classList.remove("down");
// ...
Then for the animation, a little extra CSS is required.
#toolbar.down {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 500ms;
}
And that's it! Your toolbar or any fixed/sticky header will reappear nicely on the screen, even when the soft keyboard is open.
Here's the final code:
let fixPosition = 0; // the fix
let lastScrollY = window.pageYOffset; // the last scroll position
let toolbarWrap = document.getElementById('toolbar-wrap'); // the toolbar wrap
let toolbar = document.getElementById('toolbar'); // the toolbar
let editor = document.getElementById('editor'); // the editor
// function to set the margin to show the toolbar if hidden
const setMargin = function() {
// if toolbar wrap is hidden
const newPosition = toolbarWrap.getBoundingClientRect().top;
if (newPosition < -1) {
// add a margin to show the toolbar
toolbar.classList.add("down"); // add class so toolbar can be animated
fixPosition = Math.abs(newPosition); // this is new position we need to fix the toolbar in the display
// if at the bottom of the page take a couple of pixels off due to gap
if ((window.innerHeight + window.pageYOffset) >= document.body.offsetHeight) {
fixPosition -= 2;
}
// set the margin to the new fixed position
toolbar.style["margin-top"] = fixPosition + "px";
}
}
// use lodash debounce to stop flicker
const debounceMargin = _.debounce(setMargin, 150);
// function to run on scroll and blur
const showToolbar = function() {
// remove animation and put toolbar back in default position
if (fixPosition > 0) {
toolbar.classList.remove("down");
fixPosition = 0;
toolbar.style["margin-top"] = 0 + "px";
}
// will check if toolbar needs to be fixed
debounceMargin();
}
// add an event listener to scroll to check if
// toolbar position has moved off the page
window.addEventListener("scroll", showToolbar);
// add an event listener to blur as iOS keyboard may have closed
// and toolbar postition needs to be checked again
editor.addEventListener("blur", showToolbar);
#toolbar-wrap {
position: sticky;
top: 0px;
height: 60px;
width: 100%;
}
#toolbar {
position: absolute;
left: 0px;
right: 0px;
padding: 15px;
background-color: #f1f5f9;
}
#toolbar.down {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 500ms;
}
<div id="toolbar-wrap">
<div id="toolbar">
<!-- your toolbar content... -->
</div>
</div>
<!-- rest of the page content... -->
You can also view the fixed codepen demo, but you will need to fork it and open it on your phone/tablet in debug mode to see it in action (because the iframe view prevents the fix from working properly!).
See the Pen iOS keyboard vs fixed header (the fix) by Codemzy (@codemzy) on CodePen.
Don't forget to fork the pen and view on your mobile device in debug mode to see the fix in action!