You're a North Korean engineer who's been selected to develop a new government project. It's an HTML form, which North Korean political leaders will fill in for [REDACTED] purposes.

One of the fields requires the user to select the title by which they prefer to be addressed. Since the list can get pretty long, you decide to go for your good old <select> element. It looks like this:

Windows (Chrome) macOS (Safari)

Nothing out of the ordinary, perfectly acceptable in most cases.

You know that <select> has that kind of "search" that jumps to the items as you type. But you're not sure if the Great Leader is aware of this. You feel like this is not too big of a deal, as long as the list is in alphabetical order.

What about mobile?

Android (Chrome) iOS (Safari)

Android tries to use as much of the screen as possible, covering the address bar. On iOS, the small number of visible items makes for an awful experience with larger lists. Both of them lack a way to search or filter list items.

Will the Father of the Nation look the other way? Not wanting to take any chances, you take this matter into your own hands. You want something that can be filtered on mobile, and makes better use of screen real estate.

On desktop platforms this is not too hard to achieve: just a custom dropdown with a text input for filtering. For mobile, you'll need something different. Let's focus on the mobile version, and presume that you'll have some way to pick the correct implementation depending on the platform.

This is your plan for mobile:

A full-screen modal with a fixed text input at the top for filtering, and a scrollable list of items below it. Your first instinct tells you the implementation should go like this:



<button onclick= "openModal()" > Select a title </button> <div class= "modal" id= "modal" > <div class= "modal-header" > <input type= "text" id= "filter-input" > <button onclick= "closeModal()" > X </button> </div> <div class= "modal-body" > <button> Item 1 </button> <button> Item 2 </button> <!-- remaining items... --> </div> </div>

.modal { display : none ; position : fixed ; top : 0 ; left : 0 ; height : 100vh ; flex-direction : column ; } .modal.show { display : flex ; } .modal-body { flex : 1 ; overflow-y : auto ; }

const modal = document . getElementById ( ' modal ' ) const filterInput = document . getElementById ( ' filter-input ' ) function openModal () { modal . classList . add ( ' show ' ) filterInput . focus () } function closeModal () { modal . classList . remove ( ' show ' ) }

The important bits:

position: fixed to fix the modal to the screen;

to fix the modal to the screen; height: 100vh to make the height 100% of viewport's;

to make the height 100% of viewport's; Modal divided in two parts: header and body;

Header's height defined by its children, no need to set it explicitly;

Body fills the remaining height with flex: 1 ;

; scrolly-y: auto in the body to make it scrollable when the list doesn't fit.

It looks like this:

Android (Chrome) iOS (Safari)

Looks good on iOS, but on Android the last items are being cut off. Why?

Some mobile browsers hide the address bar when the user scrolls down. This changes the visible viewport height, but not the meaning of 100vh . So 100vh actually a bit taller than what is initially visible.

Your modal has position: fixed , so you don't need to use vh units. height: 100% will fill the available height correctly:

Neat! This is already an improvement from the native versions of <select> on mobile. Now you need to implement the filter behavior.

You're pretty sure that your Guiding Sun Ray wouldn't want to go through the trouble of having to touch the filter input every time after opening the modal. So you should focus() the filter input as soon as the modal opens. This way, the keyboard pops up and the user can start typing right away. Let's see how it looks:

Android (Chrome) iOS (Safari)

This time everything looks fine on Android. On iOS, the modal header is scrolled out of bounds once you try to scroll the list. What's going on?

iOS without Keyboard iOS with Keyboard

When you filter by "Leader", the list becomes small enough to fit the screen without scrolling, but only if the keyboard isn't visible. On Android, opening the keyboard shrinks the viewport down to the visible area. But on iOS, the viewport size remains unchanged; it is just being covered by the keyboard. iOS lets you scroll the page while the keyboard is open, revealing that missing portion of the page. This behavior can break position: fixed elements like yours.

To make matters worse, there's no way to know how tall the keyboard will be, or if it is there at all (the user can be using a hardware keyboard). No clever CSS trick can save you this time.

So you need to have a scrollable list, where all the items are accessible, without knowing if an arbitrary portion of the lower part of screen is visible or not. This is your workaround:

You add a spacer at the bottom of the list (highlighted in green for visibility). The height of this spacer is the height of the list area, minus one element. This way, it's always possible to scroll all the way to the bottom, bringing the last element to the very top of the list.

There are still ways to make the modal scroll outside the viewport, and you need to patch them.

One way is by swiping on any non-scrollable elements currently visible. In your case, that's the modal header. You can't just disable all pointer events through CSS, since you need the inner elements (filter input and close button) to still be usable. The solution is to disable scrolling on touchmove events:



const header = document . getElementById ( ' modal-header ' ) header . addEventListener ( ' touchmove ' , event => { event . preventDefault () })

The default reaction to touchmove is scrolling, so blocking that with preventDefault() will make it unscrollable.

Now let's take a small detour. I've been writing these examples in HTML + JavaScript to make the article a bit more universal. But I came across this spiral of workarounds while developing in React. This is how I define my event handler in React:



function handleTouchMove ( event ) { event . preventDefault () } // … < Element onTouchMove = { handleTouchMove } />

The expectation might be that in plain JavaScript, this would translate to something like this:



const element = document . getElementById ( ' element ' ) element . addEventListener ( ' touchmove ' , event => { // call the callback for this element })

But what happens is closer to this (not real code):



document . addEventListener ( ' touchmove ' , event => { const element = React . getElementFromEvent ( event ) // call the callback for this element })

React binds the events at the document level, instead of binding them at the level of each individual node. Here is what happens when I try to preventDefault() touch events in React:

The browser blocks it. This was introduced with a Chrome update that made events be "passive" by default, and those can't be blocked with preventDefault at the document level. The solution is to bind the event manually at the node level, instead of doing it through React's event system:



ref = React . createRef (); componentDidMount () { ref . addEventListener ( ' touchmove ' , handleTouchMove ) } function handleTouchMove ( event ) { event . preventDefault () } // … < Element ref = { ref } onTouchMove = { handleTouchMove } />

So yes, particularly in React, this workaround requires a workaround.

As I write this, React's event system is being rewritten, so the problem may no longer exist by the time you read this article.

Now back to your problem.

There is one more way to scroll your hopes and dreams away. If the user insists on scrolling when there are no more items to show, the viewport can be scrolled up. None of this fazes you anymore, you just jam another workaround in there:



const modalBody = document . getElementById ( ' modal-body ' ) menuScroll = () => { if ( modalBody . scrollHeight - modalBody . scrollTop === modalBody . clientHeight ) { modalBody . scrollTop -= 1 } } modalBody . addEventListener ( ' scroll ' , menuScroll )

You push the list's scroll position one pixel away from the edge when the scroll reaches the bottom. This way, the outer scroll is never triggered.

The solution is already pretty solid, but there is one more thing you'd like to improve. The modal suddenly covering the screen might be a bit jarring. What if His Excellency isn't paying attention and gets spooked? Who will take care of your kids?

A simple transition animation could make it easier to follow. Perhaps you could slide the modal from the bottom of the screen? Easy to achieve with CSS transitions:



.modal { /* ... */ display : flex ; top : 100vh ; transition : top 500ms ; } .modal.show { top : 0 ; }

Now, instead of initializing your modal with display: none and top: 0 , you start it already with display: flex , but pushed outside the viewport with top: 100vh . When the modal is set to visible, it will scroll smoothly to the top of the screen. Let's see the results:

Android (Chrome) iOS (Safari)

So close! Android is behaving well again, while iOS blasts the modal to outer space as soon as it is visible. It seems like toggling the keyboard while the modal is being animated isn't a good idea. You feel pretty confident that showing the keyboard only after the animation is done should fix it:



function openModal () { modal . classList . add ( ' show ' ) // new setTimeout (() => { filterInput . focus () }, 500 ) }

Simple enough. You wait for 500ms, the same as the transition duration, and only then you focus() the input to make the keyboard pop up. You tell yourself that you'll clean this up later, maybe using events or some fancy library, instead of relying on the values being consistent between JS and CSS. But you know it won't happen. The result:

Android iOS

iOS doesn't seem to be focusing the input at all. Of course, it couldn't be that easy. iOS only allows focus events to happen as a direct result of a user interaction, and setTimeout isn't that. Your workaround is to turn the "Select a title" button into a text input:



<input onfocus= "openModal()" readonly= true placeholder= "Select a title" >

The readonly hides the caret and makes sure the user can't type anything into this new input during the transition. This way, iOS will show the keyboard based on the first focus event, allowing you to change the focus to the second input after the transition is done.

And it works! You're finally done. You feel proud of your work, knowing your family will live at least another couple months.

Find the full code for the modal here