Scrollbars are one of those things you usually do not pay much attention until you need to implement one. In a very basic form, there is just some straightforward math to learn, and you are good to go. To maintain good UX you usually want to have a minimum size for the scroll grip (the thing a user can drag), which causes math to be a bit more tricky, but nothing crazy.
Real trouble begins though when you want to add marks on the scrollbar that correspond to certain positions in the document - like the ones your favorite editor shows for errors / warnings. So let's take a journey down the rabbit hole...
The Basics
The main job of a scrollbar is to represent the position of the user's viewport into a document, and to allow the user to adjust said viewport by either dragging the grip or clicking somewhere on the track. Some scrollbars also provide arrow buttons at the end for small adjustment, but I will ignore this type of interaction for this article as it does not affect the topic discussed.
Scrollbars can be vertical or horizontal, and since the math does not really change, except for
using x
vs y
and width
vs height
, I will use the horizontal version for the demo purposes.
Here's a typical scrollbar with the components named for clarity and document contents indicated by the vertical bars:
With the component names clarified we can start with the math required to calculate the size of the grip:
let documentSize = 1000;
let containerSize = 200;
let trackSize = containerSize;
let gripSize =
containerSize / documentSize * trackSize;
Very first gotcha with the math above is that if your document
is smaller than the container
your grip
size will end up being larger than the track. If that happens you need to either hide
the scrollbar altogether or limit the grip size with something like this:
gripSize = gripSize > trackSize
? trackSize
: gripSize;
Next, let's calculate offset of the grip
given the document scroll offset. In the majority of UI
systems (except for Apple iOS / macOS) it is not allowed to scroll past the end of the content,
so the maximum scroll of is difference between the document
size and container
size. Similarly,
we do not want for grip
to go outside of the track, so max grip
offset is reduced by its size.
With this in place, calculation of a grip offset based on document scroll is matter of applying
the document
scroll over size proportion to the grip
and track
:
let documentScroll = 400;
let maxDocumentScroll =
documentSize - containerSize;
let maxGripOffset = containerSize - gripSize;
let documentScrollProportion =
documentScroll / maxDocumentScroll;
let gripOffset =
documentScrollProportion * maxGripOffset;
If the user is allowed to drag the grip to change the scroll position of the document, then we also need to solve the proportion in the opposite direction:
let newGripOffset = 20;
let gripOffsetProportion =
newGripOffset / maxGripOffset;
let newDocumentScroll =
gripOffsetProportion * maxDocumentScroll;
Putting all of this together, we get the scrollbar behavior that also has the calculations visualized for clarity:
Grip Minimum Size
Astute reader might note that with the current setup if we increase the size of the document and / or decrease container size, then at some point the size of the grip becomes too small to comfortable interact with or look at:
The good news is that we can ensure minimum size of the grip in the same way that we ensured max size, and the math still works out... mostly.
let minGripSize = 40;
gripSize = gripSize < minGripSize
? minGripSize
: gripSize;
If your drag around the grip in the example below then everything seems to work out fine, and indeed this is the math and setup that majority, if not all, scrollbars use.
So, what's wrong with it? Well, with this small change our scrollbar is no longer a birds-eye view of the document with a viewport visualized, but rather a rough indicator of the viewport's position.
This mismatch becomes a real problem if we need to mark certain positions in the document and display
them in the scrollbar. The example below has some content bars colorized, and their positions shown
in the scrollbar with the same color. The grip
is also semi-transparent so that user can see
the marks below it. And the calculation of the mark position in the scrollbar is quite straightforward:
let markOffsetInDocument = 300;
let markProportion =
markOffsetInDocument / documentSize;
let markPositionOnScrollbar =
markProportion * trackSize;
As you move the grip above the marks that are visible in the document, and the marks that are under the grip in the scrollbar often do not match.
Now that we understand the problem, let's consider our options for handling it.
How Do Others Do It?
Most often the functionality of marks is used inside IDEs and text editors. Here's the summary of the ones that do have scroll bar marks:
Editor* | Solution |
---|---|
IntelliJ | ❌ |
Visual Studio (Win) | no minimum size |
Visual Studio Code | ❌ |
Atom | no minimum size |
Xcode (Mac) | ❌ |
Nova (Mac) | ❌ |
Qt Creator | split projection (#3 below) |
Solution 1: Small or No Minimum Size
Probably the easiest way to mitigate the issue is to reduce the minimum size of the grip
as this increases the size of the document
necessary for the issue to appear and
reduces the effect when it does happen. Of course, you should still follow the UI and
accessibility guidelines for your target platform regarding minimum area for interactive
elements. You can adjust minimum size of the grip
in the example below and see the effect.
Solution 2: Separate Grip Drag Area From Visual Area
The idea behind this solution is to draw the grip
without the minimum size, and then overlay
larger grip, either permanently or on hover, so that user can comfortably interact with the grip
while being able to accurately map scroll position to the document
area.
Depending on the UI design and requirements you might also choose to allow an overhang of the "drag grip" like shown below:
Solution 3: Split Projection
I do not know if the title of the section adequately represents the idea, but I could not come up with something better so here we go. Also as mentioned above, this is the solution used in Qt Creator.
Our target state is to have grip and marks below (on) it be an accurate, scaled representation of the visible part of the document. The solution would then to draw the grip and marks scaled up and then proportionally scale down the remaining free track space before and after the grip. The implementation of such an approach is not too complicated. Instead of projecting the whole document to the whole track we split the document into three pieces and map accordingly with their own separate scaling: before viewport → before grip, viewport → grip, and after viewport → after grip.
// grip position / size same as in basic code above
let beforeProportion =
gripOffset / documentScroll;
let gripProportion =
gripSize / containerSize;
let documentAfterStart =
documentScroll + containerSize;
let gripAfterStart = gripOffset + gripSize;
let afterProportion =
(trackSize - gripAfterStart) /
(documentSize - documentAfterStart);
let marks = [100, 180, 300/* , ... */];
for (let offsetInDocument of marks) {
if (offsetInDocument < documentScroll) {
offsetInDocument *= beforeProportion;
} else if (offsetInDocument > documentAfterStart) {
offsetInDocument -= documentAfterStart;
offsetInDocument *= afterProportion;
offsetInDocument += gripAfterStart;
} else { // Inside viewport
offsetInDocument -= documentScroll;
offsetInDocument *= gripProportion;
offsetInDocument += gripOffset;
}
// ... your painting code
}
The result, as seen below looks a bit a like a lens effect inside the grip when we move it around, but it does maintain the correct mapping of the document content to scrollbar at all times:
Summary
I think all three solutions have their place depending on the project requirements, and I believe all of them provide better trade-offs than just ignoring the problem which seems to be what is happening with the majority of code editors at least.