Responsive tabs without UI libraries for React and Angular
Responsive tabs are quite easy for implementation in case the size of each tab is the same. But how is it possible to make the tabs fully responsive when the size of each tab depends on the content inside it and can vary for different tabs? This article will help to find a solution for both React and Angular web applications. Let’s start!
This feature can be useful for such parts of web interface as tabs, list actions and all other actions that should be shown to a user as a list of horizontally located options. The main idea behind this implementation is the fact that we need all the tabs present in DOM all the time and ResizeObserver to measure space available for these tabs. That’s enough to calculate tabs visibility and control its responsiveness. Data for tabs that should be hidden will be added to the array of items, which will be shown by clicking the button “More”.
Common code for both React and Angular
We will start with adding several constants to a separate constants file. These variables are the same for both React and Angular implementations:
- Actions (tabs) data list:
export const ACTIONS_LIST = [
{
value: 1,
name: "One"
},
{
value: 2,
name: "Two"
},
{
value: 3,
name: "Three"
},
{
value: 4,
name: "Four"
},
{
value: 5,
name: "Five"
},
{
value: 6,
name: "Six"
},
{
value: 7,
name: "Seven"
},
{
value: 8,
name: "Eight"
},
{
value: 9,
name: "Nine"
},
{
value: 10,
name: "Ten"
}
];
2. It’s necessary to define the gap in pixels between tabs inside js constant because we will need this number for further available space calculations:
export const ACTION_BTNS_GAP = 16;
3. The last one constant is width in pixels which will be reserved for the button “More”. Content inside the button could be different so the reserved space can vary. Also, it’s possible to define the size of this button by checking its actual width in HTML. Feel free to use this approach for your own code. But here we won’t calculate width for this button using JS to avoid the component overload with additional code.
export const MORE_BTN_RESERVED_WIDTH = 90;
Of course our code won’t work properly without styles. We will put styles in this section of the article because they are the same for both React and Angular. Feel free to scroll down to React or Angular section with the corresponding HTML to compare class names from css file with the actual tags structure.
body {
font-family: sans-serif;
background-image: url('https://images.unsplash.com/photo-1497250681960-ef046c08a56e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTh8fGxlYWZ8ZW58MHx8MHx8fDA%3D&auto=format&fit=crop&w=500&q=60');
background-size: cover;
background-repeat: repeat;
padding: 70px 30px;
}
/* use :host instead of .wrapper for Angular */
.wrapper,
.actions-wrapper {
position: relative;
}
.main-actions {
flex-shrink: 1;
display: flex;
align-items: center;
overflow: hidden;
}
.action {
padding: 10px 20px;
border-radius: 20px;
background: papayawhip;
border: 1px solid darkseagreen;
width: fit-content;
}
.more-btn {
position: absolute;
top: 0;
padding: 10px 20px;
box-sizing: border-box;
border-radius: 20px;
background: darkseagreen;
cursor: pointer;
}
.more-options {
position: absolute;
top: 50px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 6px;
border: 1px solid darkseagreen;
background: papayawhip;
}
.visible {
visibility: visible;
}
.hidden {
visibility: hidden;
}
In case you decide to use styles for body from the example above pay attention that it’s necessary to put body styles to global styles file for Angular. Otherwise they won’t work for body. Actually, in any case styles for body shouldn’t be inside any component in React too, but this is not the topic of this article so we use a simplified example.
The most important idea we should understand behind this implementation is that we need 2 main things:
- ResizeObserver to track the actual size of the container with the tabs;
- all tab buttons actual width, which we will use to define if there is enough free space in the container to show the next tab OR we need to put the next tab to the list of hidden tabs and show the button “More”.
The calculations for tabs visibility below could seem a little bit complicated. But if you dive into the main idea, they become much more obvious.
We will start with React explanations and after that discuss implementation using Angular.
React implementation
Let’s create a basic layout using class names from the styles files above:
export default function App() {
const containerRef = useRef(null);
const containerVisibleWidth = useRef(0);
const actionElementsWidth = useRef([]);
const moreBtnLeftPosition = useRef(0);
const [actionsMoreList, setActionsMoreList] = useState([]);
const [isMoreOpen, setIsMoreOpen] = useState(false);
return (
<div className="wrapper">
<div className="actions-wrapper">
<div
className="main-actions"
style={{ gap: ACTION_BTNS_GAP }}
ref={containerRef}
>
{ACTIONS_LIST.map((action) => (
<div key={action.value} className="action">
{action.name}
</div>
))}
</div>
<div
className={`more-btn ${
actionsMoreList.length ? "visible" : "hidden"
}`}
style={{ left: moreBtnLeftPosition.current }}
onClick={() => setIsMoreOpen(!isMoreOpen)}
>
More...
</div>
</div>
<div
className={`more-options ${isMoreOpen ? "visible" : "hidden"}`}
style={{ left: moreBtnLeftPosition.current }}
>
{actionsMoreList.map((action) => (
<div className="action" key={action.value}>
{action.name}
</div>
))}
</div>
</div>
);
}
Some explanations for this part of code are below.
We will need a couple of React state variables:
- const [actionsMoreList, setActionsMoreList] = useState([]) — for the list of actions which should be added to the hidden list “inside” the button “More” ;
- const [isMoreOpen, setIsMoreOpen] = useState(false) — for boolean value which is responsible for open-close state of the additional actions list.
Moreover, it will be necessary to add several refs for HTML elements and as a storage for essential data inside the component.
Element ref will include:
- containerRef added to div which wraps visible tabs.
Refs as data storage include:
- containerVisibleWidth — we will need this number later for the function which will define if it’s enough free space for visible tabs;
- actionElementsWidth — here we will keep an array of width numbers for each tab button element. An essential point here is that all the tabs in “visible” tabs list should be present in DOM. Therefore we will use visibility: hidden and not display: none in css. Otherwise we won’t have an opportunity to collect all the tabs width numbers to array for actionElementsWidth.current. Later we need data from this array to define each tab current condition: visible or hidden, depending on the available free space in the container;
- moreBtnLeftPosition — we will need this ref to define a position for “More” button. Our component has a wrapper <div className=”wrapper”> which have position: relative and div with className “more-options” which have position: absolute. As left property can vary for different screen sizes, data from moreBtnLeftPosition ref will help us to keep the position of “More” button updated all the time.
Also in the code above we added:
- onClick handling for “More” button, this button is responsible for opening and closing the list with hidden actions;
- render for action data using discussed above constant ACTIONS_LIST;
- ACTION_BTNS_GAP constant usage;
- moreBtnLeftPosition usage inside HTML;
- all necessary classNames;
And now it’s time for magic! Let’s add all the necessary calculations for this feature to our component.
We will need to add:
- useEffect which will be responsible for adding all the tabs width numbers to array which we store inside actionElementsWidth.current. Also we will add ResizeObserver here to track the size of tabs container;
- calculateVisibility function will be executed every time when ResizeObserver detects the changes.
Code below has additional comments to explain the logic behind all these calculations:
// add these functions to previously created App component
const calculateVisibility = (actionElements) => {
// variable for actual visible tabs width + gaps between tabs calculations
// to define if the next tab has a free space to be visible or not
let visibleElementsWidth = 0;
// variable for the list of hidden tabs which will be put to react state
const actionsMoreData = [];
// variable which works as a flag and changes to false with the
// first hidden tab during actionElements iteration
let isVisible = true;
[...actionElements].forEach((actionEl, i) => {
// necessary gap after the tab
const gapWidth = i === actionElements.length - 1 ? 0 : ACTION_BTNS_GAP;
// visibleElementsWidth will be increased by
// the corresponding width of the element + gapWidth
visibleElementsWidth += actionElementsWidth.current[i] + gapWidth;
// calculates how much space is necessary for all the previous
// tabs + the next tab or button "More"
const visibleSpaceWidth =
i !== actionElements.length - 1
? visibleElementsWidth + MORE_BTN_RESERVED_WIDTH
: visibleElementsWidth;
// compare if container's actual width is enough to show all
// the elements that need space equal to visibleSpaceWidth width
if (visibleSpaceWidth <= containerVisibleWidth.current && isVisible) {
// add classNames for visible tabs
actionEl.className = "action visible";
} else {
if (isVisible) {
// calculate left property for button "More" which has
// absolute position
moreBtnLeftPosition.current =
actionElementsWidth.current
.slice(0, i)
.reduce((acc, item) => item + acc, 0) +
ACTION_BTNS_GAP * i;
// set isVisible to false for the first hidden tab
isVisible = false;
}
// add classNames for hidden tabs
actionEl.className = "action hidden";
// update actionsMoreData with the new hidden tab's data
actionsMoreData.push(ACTIONS_LIST[i]);
}
});
// update React state with the list of hidden tabs
setActionsMoreList([...actionsMoreData]);
};
useEffect(() => {
const actionElements = containerRef.current?.children || [];
// defining the actual width of each tab
const actionsListWidth = [];
[...actionElements].forEach((actionEl) => {
actionsListWidth.push(actionEl.offsetWidth);
});
// update the array with all the tabs width numbers,
// stored inside actionElementsWidth ref
actionElementsWidth.current = [...actionsListWidth];
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentBoxSize) {
const contentBoxSize = entry.contentBoxSize[0];
// Math.ceil is necessary to round up and return
// the smallest integer for the size of observed element
containerVisibleWidth.current = Math.ceil(contentBoxSize.inlineSize);
// invoke the functions which calculates tabs visibility
// and sets data to the list of hidden tabs
calculateVisibility(actionElements);
}
}
});
// adding ResizeObserver to the observed container
resizeObserver.observe(containerRef.current);
}, []);
That’s it! Our completed component should look like this:
import { useState, useRef, useEffect } from "react";
import {
ACTIONS_LIST,
ACTION_BTNS_GAP,
MORE_BTN_RESERVED_WIDTH
} from "./constants";
import "./styles.scss";
export default function App() {
const containerRef = useRef(null);
const containerVisibleWidth = useRef(0);
const actionElementsWidth = useRef([]);
const moreBtnLeftPosition = useRef(0);
const [actionsMoreList, setActionsMoreList] = useState([]);
const [isMoreOpen, setIsMoreOpen] = useState(false);
const calculateVisibility = (actionElements) => {
let visibleElementsWidth = 0;
const actionsMoreData = [];
let isVisible = true;
[...actionElements].forEach((actionEl, i) => {
const gapWidth = i === actionElements.length - 1 ? 0 : ACTION_BTNS_GAP;
visibleElementsWidth += actionElementsWidth.current[i] + gapWidth;
const visibleSpaceWidth =
i !== actionElements.length - 1
? visibleElementsWidth + MORE_BTN_RESERVED_WIDTH
: visibleElementsWidth;
if (visibleSpaceWidth <= containerVisibleWidth.current && isVisible) {
actionEl.className = "action visible";
} else {
if (isVisible) {
moreBtnLeftPosition.current =
actionElementsWidth.current
.slice(0, i)
.reduce((acc, item) => item + acc, 0) +
ACTION_BTNS_GAP * i;
isVisible = false;
}
actionEl.className = "action hidden";
actionsMoreData.push(ACTIONS_LIST[i]);
}
});
setActionsMoreList([...actionsMoreData]);
};
useEffect(() => {
const actionElements = containerRef.current?.children || [];
const actionsListWidth = [];
[...actionElements].forEach((actionEl) => {
actionsListWidth.push(actionEl.offsetWidth);
});
actionElementsWidth.current = [...actionsListWidth];
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentBoxSize) {
const contentBoxSize = entry.contentBoxSize[0];
containerVisibleWidth.current = Math.ceil(contentBoxSize.inlineSize);
calculateVisibility(actionElements);
}
}
});
resizeObserver.observe(containerRef.current);
}, []);
return (
<div className="wrapper">
<div className="actions-wrapper">
<div
className="main-actions"
style={{ gap: ACTION_BTNS_GAP }}
ref={containerRef}
>
{ACTIONS_LIST.map((action) => (
<div key={action.value} className="action">
{action.name}
</div>
))}
</div>
<div
className={`more-btn ${
actionsMoreList.length ? "visible" : "hidden"
}`}
style={{ left: moreBtnLeftPosition.current }}
onClick={() => setIsMoreOpen(!isMoreOpen)}
>
More...
</div>
</div>
<div
className={`more-options ${isMoreOpen ? "visible" : "hidden"}`}
style={{ left: moreBtnLeftPosition.current }}
>
{actionsMoreList.map((action) => (
<div className="action" key={action.value}>
{action.name}
</div>
))}
</div>
</div>
);
}
You can find the full React implementation on Codesandbox:
Angular implementation
Now let’s implement this solution for Angular. We will also start with a basic component and its layout creation, also we will add all the necessary styles.
But firstly we will add one more file with models constants.ts. It’s pretty simple and contains just one model:
export interface IAction {
value: number;
name: string;
}
The basic component should look like this:
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements AfterViewInit {
@ViewChild('containerRef') private containerRef: ElementRef<HTMLDivElement>;
@ViewChildren('action', { read: ElementRef })
private readonly actionElements: QueryList<ElementRef>;
public containerVisibleWidth = 0;
public actionElementsWidth: number[] = [];
public actionsMoreList: IAction[] = [];
public moreBtnLeftPosition = 0;
public isMoreOpen = false;
public readonly actionsList = ACTIONS_LIST;
public readonly actionBtnsGap = ACTION_BTNS_GAP;
constructor(
private readonly cdr: ChangeDetectorRef,
private renderer: Renderer2
) {}
public ngAfterViewInit(): void {
// we will add some logic here later
}
public handleClickMore(): void {
this.isMoreOpen = !this.isMoreOpen;
this.cdr.detectChanges();
}
}
What we have here:
- containerRef added to div which wraps visible tabs;
- action — will work as a ref for the tabs in the main container;
- containerVisibleWidth, actionElementsWidth, moreBtnLeftPosition — these variables have totally identical puprose as for React implementation. So check explanations for them in React chapter of this article, please.
- isMoreOpen variable and function handleClickMore are responsible for opening and closing the list with hidden actions.
- ACTIONS_LIST and ACTION_BTNS_GAP constants usage was added.
Now we can add all the necessary html:
<div class="actions-wrapper">
<div
class="main-actions"
[ngStyle]="{ gap: actionBtnsGap + 'px' }"
#containerRef
>
<div
*ngFor="let action of actionsList"
class="action"
#action
>{{ action.name }}</div>
</div>
<div
*ngIf="!!actionsMoreList?.length"
class="more-btn"
[style.left.px]="moreBtnLeftPosition"
(click)="handleClickMore()"
>
More...
</div>
</div>
<div
*ngIf="isMoreOpen"
class="more-options"
[style.left.px]="moreBtnLeftPosition"
>
<div
class="action"
*ngFor="let action of actionsMoreList"
>
{{ action.name }}
</div>
</div>
Let’s do some magic for Angular too and add logic for ngAfterViewInit and a function calculateVisibility which will do all the calculations for our responsive tabs list!
Code below has additional comments to explain the logic of all the actions:
public ngAfterViewInit(): void {
// defining the actual width of each tab
const actionsListWidth: number[] = [];
this.actionElements.forEach((actionEl) => {
actionsListWidth.push(actionEl.nativeElement.offsetWidth);
});
// update the array with all the tabs width numbers,
// stored inside this.actionElementsWidth
this.actionElementsWidth = [...actionsListWidth];
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentBoxSize) {
const contentBoxSize = entry.contentBoxSize[0];
// Math.ceil is necessary to round up and return
// the smallest integer for the size of observed element
this.containerVisibleWidth = Math.ceil(contentBoxSize.inlineSize);
// invoke the functions which calculates tabs visibility
// and sets data to the list of hidden tabs
this.calculateVisibility();
}
}
});
// adding ResizeObserver to the observed container
resizeObserver.observe(this.containerRef.nativeElement);
}
private calculateVisibility(): void {
// variable for actual visible tabs width + gaps between tabs calculations
// to define if the next tab has a free space to be visible or not
let visibleElementsWidth = 0;
// variable for the list of hidden tabs which will be
// put to this.actionsMoreList
const actionsMoreData: IAction[] = [];
// variable which works as a flag and changes to false with the
// first hidden tab during actionElements iteration
let isVisible = true;
this.actionElements.forEach((actionEl, i) => {
// necessary gap after the tab
const gapWidth =
i === this.actionElements.length - 1 ? 0 : this.actionBtnsGap;
// visibleElementsWidth will be increased by
// the corresponding width of the element + gapWidth
visibleElementsWidth += this.actionElementsWidth[i] + gapWidth;
// calculates how much space is necessary for all the previous
// tabs + the next tab or button "More"
const visibleSpaceWidth =
i !== this.actionElements.length - 1
? visibleElementsWidth + MORE_BTN_RESERVED_WIDTH
: visibleElementsWidth;
// compare if container's actual width is enough to show all
// the elements that need space equal to visibleSpaceWidth width
if (visibleSpaceWidth <= this.containerVisibleWidth && isVisible) {
// adjust class names for visible tabs
this.renderer.removeClass(actionEl.nativeElement, 'hidden');
this.renderer.addClass(actionEl.nativeElement, 'visible');
} else {
if (isVisible) {
// calculate left property for button "More" which has
// absolute position
this.moreBtnLeftPosition =
this.actionElementsWidth
.slice(0, i)
.reduce((acc, item) => item + acc, 0) +
this.actionBtnsGap * i;
// set isVisible to false for the first hidden tab
isVisible = false;
}
// adjust class names for hidden tabs
this.renderer.removeClass(actionEl.nativeElement, 'visible');
this.renderer.addClass(actionEl.nativeElement, 'hidden');
// update actionsMoreData with the new hidden tab's data
actionsMoreData.push(this.actionsList[i]);
}
});
// update this.actionsMoreList with the list of hidden tabs
this.actionsMoreList = [...actionsMoreData];
this.cdr.detectChanges();
}
And the whole component.ts file should look like this:
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
QueryList,
Renderer2,
ViewChild,
ViewChildren,
} from '@angular/core';
import { IAction } from './models';
import {
ACTIONS_LIST,
ACTION_BTNS_GAP,
MORE_BTN_RESERVED_WIDTH,
} from './constants';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements AfterViewInit {
@ViewChild('containerRef') private containerRef: ElementRef<HTMLDivElement>;
@ViewChildren('action', { read: ElementRef })
private readonly actionElements: QueryList<ElementRef>;
public containerVisibleWidth = 0;
public actionElementsWidth: number[] = [];
public actionsMoreList: IAction[] = [];
public moreBtnLeftPosition = 0;
public isMoreOpen = false;
public readonly actionsList = ACTIONS_LIST;
public readonly actionBtnsGap = ACTION_BTNS_GAP;
constructor(
private readonly cdr: ChangeDetectorRef,
private renderer: Renderer2
) {}
public ngAfterViewInit(): void {
const actionsListWidth: number[] = [];
this.actionElements.forEach((actionEl) => {
actionsListWidth.push(actionEl.nativeElement.offsetWidth);
});
this.actionElementsWidth = [...actionsListWidth];
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentBoxSize) {
const contentBoxSize = entry.contentBoxSize[0];
this.containerVisibleWidth = Math.ceil(contentBoxSize.inlineSize);
this.calculateVisibility();
}
}
});
resizeObserver.observe(this.containerRef.nativeElement);
}
private calculateVisibility(): void {
let visibleElementsWidth = 0;
const actionsMoreData: IAction[] = [];
let isVisible = true;
this.actionElements.forEach((actionEl, i) => {
const gapWidth =
i === this.actionElements.length - 1 ? 0 : this.actionBtnsGap;
visibleElementsWidth += this.actionElementsWidth[i] + gapWidth;
const visibleSpaceWidth =
i !== this.actionElements.length - 1
? visibleElementsWidth + MORE_BTN_RESERVED_WIDTH
: visibleElementsWidth;
if (visibleSpaceWidth <= this.containerVisibleWidth && isVisible) {
this.renderer.removeClass(actionEl.nativeElement, 'hidden');
this.renderer.addClass(actionEl.nativeElement, 'visible');
} else {
if (isVisible) {
this.moreBtnLeftPosition =
this.actionElementsWidth
.slice(0, i)
.reduce((acc, item) => item + acc, 0) +
this.actionBtnsGap * i;
isVisible = false;
}
this.renderer.removeClass(actionEl.nativeElement, 'visible');
this.renderer.addClass(actionEl.nativeElement, 'hidden');
actionsMoreData.push(this.actionsList[i]);
}
});
this.actionsMoreList = [...actionsMoreData];
this.cdr.detectChanges();
}
public handleClickMore(): void {
this.isMoreOpen = !this.isMoreOpen;
this.cdr.detectChanges();
}
}
You can find the full Angular implementation on Codesandbox:
Hope these examples will be useful in your projects!