TabPanel
A utility control for displaying blocks of related content is the TabPanel control.
In the example below, a row of classic semi-rounded Tabs is presented above a fixed height container where content is loaded according to the topic displayed in the Tab.
Example
Description
This example hyperComponent uses htmx to issue AJAX requests to an Astro API endpoint which returns a hypermedia response that is loaded into the container below the tabs.
Switching tabs is performed exclusively by HyperScript. Styling is provided only by Tailwind utility classes.
There are two Astro components used to implement this control, specifically a TabPanel component which acts as a parent container that wraps multiple TabPanelItem components.
---import TabPanel from '/components/tabs/TabPanel.astro'import TabPanelItem from '/components/tabs/TabPanelItem.astro'---
<TabPanel defaultTabId="1"> <TabPanelItem id="0" caption="Hypermedia" path="/api/endpoint/0" trigger="load, click" /> <TabPanelItem id="1" caption="HTMX" path="/api/endpoint/1" /> <TabPanelItem id="2" caption="Tailwind" path="/api/endpoint/2" /> <TabPanelItem id="3" caption="Hyperscript" path="/api/endpoint/3" /> <TabPanelItem id="4" caption="Astro" path="/api/endpoint/4" /></TabPanel>Features
- A single fixed-height panel with a top row of tabs
- Height of panel can be changed via props.
- Panel content is loaded from custom API routes using htmx
- Customizable appearance of active and inactive tabs
- Customizable appearance of the panel content
- Smooth transitions between content swapping
Components
TabPanel
The main container for this control is the TabPanel component.
The following sections break down the code with explanations for the Props, Slots, Events, Attributes, and Transitions provided by this component.
All the source code you need to implement this component in your project is presented in various tabs of the Code section below.
Props
interface Props { defaultTabId?:string maxHeight?:string}
const { defaultTabId = '0', maxHeight = 'max-h-[35rem]'} = Astro.propsdefaultTabId
This prop accepts a unique identifier for a single TabPanelItem.
The Hyperscript code in the TabPanel arent component grabs this identifier and retrieves a reference to the appropriate TabPanelItem. This, in turn is sent to a custom event to activate this one panel on page load.
This identifier must match an identifier in one of the TabPanelItems or an error will occur.
The default value is ‘0’
maxHeight
This props sets the maximum height of the <output> element where the content is displayed. This should be an appropriate Tailwind max-h value and can include any extemporaneous value in brackets.
The default is max-h-[35rem].
Styling
The Component Script of the TabPanel component uses a tw constant to encapsulate all the base styles for the container, the panel’s <output/> element, as well as the individual TabPanelItems displayed as tabs at the top of the control.
Exporting the tw constant allows for sharing important Tailwind classes between the markup, the Hyperscript code, and the child TabPanelItem instances.
In order to switch the appearance of a tab when activated certain Tailwind classes must be added and removed by the Hyperscript code. If these classes were embedded into the class attribute of the appropriate DOM element, they would not be exposed to the Hyperscript code. Extracting them into the tw constant not only consolidates the markup but also makes the classes available elsewhere inside as well as outside of the TabPanel component.
Another advantage of this strategy is code maintenance. It is easier to make changes and update the styling when everything is gathered into one place, but still in proximity to the markup where it is being used (as per Locality of Behavior).
Below is the tw constant containing the entire styling code for both the container and the individual tabs.
export const tw = { container: 'tab-panel flex justify-left ', output: 'block text-left p-4 w-full overflow-y-scroll ' + 'bg-gray-100 dark:bg-gray-950 ', tabItem: 'z-10 tab-item select-none ' + 'font-semibold rounded-t-xl cursor-pointer ' + 'hover:bg-orange-100 dark:hover:bg-purple-900 ' + 'border-2 border-slate-600 ', activeLight: 'bg-gray-100', activeDark: 'dark:bg-gray-950', inactiveLight: 'bg-slate-300', inactiveDark: 'dark:bg-gray-800', textActiveLight: 'text-blue-950', textActiveDark: 'dark:text-orange-200', textInactiveLight: 'text-blue-900', textInactiveDark: 'dark:text-zinc-200'}The container classes provide styling for the entire control. A tab-panel class is added to the container only to make the Hyperscript aware of parent container.
The output classes provide styling for <output> element where the content is displayed. You can, of course, apply any styles of your choice to the markup returned by you custom endpoints.
To change the appearance of the active tab, you should alter the activeLight, activeDark, textActiveLight and textActiveDark properties of the tw object.
To change the appearance of the inactive tabs, you should likewise adjust the inactiveLight, inactiveDark, textInactiveLight, and textInactiveDark properties of the tw object.
The hover effect is only present on the tabItem property so that is where you would adjust any hover colors you wish.
The Hyperscript is aware of the tw properties and will add/remove specifi classes that exist in the tw properties. If you need more customizable behaviors for the individual tabs, you will need to provide more classes to the HS code yourself. Follow the example in the scripts tab below to see how this was done.
Slots
This component contains a single default slot which receives multiple child TabPanelItems components.
Events
The TabPanel component does not emit any custom events. However, the HS code listens for the following events:
load
On page load, the defaultTabId prop is picked up by the HS code which triggers an activateTab() event passing a reference to the appropriate TabPanelItem as the argument.
activateTab(tab)
This event adds/removes classes to change the appearance of the tab which is passed as the argument. All other tabs are then deactived by sending a deactivateSiblings event.
deactivateSiblings(activeTab)
Since only one tab can be active (and its content displayed) at a time, this event is used to revert all other tabs back to their inactive appearance by switching certain utility classes.
The activeTab argument is used to to ignore the currently active tab while inactivating all others.
Attributes
hx-trigger="click" hx-swap="innerHTML transition:true" hx-target="next <output/>"One of the power features of htmx is attribute inheritance. The majority of attributes are available within children of the parent component where those attributes are applied. This spreads functionality to the children without code duplication.
Placing the following attributes on the container div of the parent TabPanel takes advantage of inheritance by providing the same attributes to all child TabPanelItems.
hx-trigger
The default trigger event for switching tabs is a ‘click’ event.
hx-swap
The swap stragegy is simple. The response is swapped into the innerHTML of the target (below) with transition:true signalling htmx to apply the ViewTransitions API for a seamless fade effect between outgoing and incoming content.
hx-target
The TabPanel component contains a single <output/> element which renders the response html.
Transitions
By adding transition:true to the hx-swap attribute, the ViewTransitions API is invoked to create a smooth fade-in appearance when the panel content is switched.
Code
In the scripts tab below notice the use of back-ticks for the hs constan. By extracting the script into a local constant and then using back-ticks with string interpolation, it is possible to embed individual TW utility classes and other props directly into the HS code while at the same time allowing the markup to utilize these same values.
You would not be able to cross-purpose the styling with the HS code if you kept the TW classes inside the class attribute of the DOM elements themselves.
Also note how the maxHeight prop is added onto the class attribute of the <output/> element by simple string concatenation. This simple trick permits props to be used in the styling as long as you don’t pass a duplicate class via props. In this case, no default max-h was specified in the tw.output property so no collision would occur. If need be, collisions or duplicates can be suppressed by using the twMerge() function illustrated in other hyperComponent examples.
For a full explanation of Tailwind collisions see the Tailwind Issues document.
interface Props { defaultTabId?:string maxHeight?:string}
const { defaultTabId = '0', maxHeight = 'max-h-[35rem]'} = Astro.props<div class={tw.container} hx-trigger="click" hx-swap="innerHTML transition:true" hx-target="next <output/>" script={hs}> <slot/></div><output class={tw.output + ' ' + maxHeight}/>const hs = ` on load set defaultTab to #{${defaultTabId}} send activateTab(tab:defaultTab) to me end -- load handler
on activateTab(tab) remove .${tw.inactiveLight} .${tw.inactiveDark} from the tab remove .${tw.textInactiveLight} .${tw.textInactiveDark} from the tab add .${tw.activeLight} .${tw.activeDark} to the tab add .${tw.textActiveLight} .${tw.textActiveDark} to the tab add .border-b-0 to the tab send deactivateSiblings(activeTab:tab) to me end -- activateTab handler
on deactivateSiblings(activeTab) set allTabs to my children for tab in allTabs if tab is not the activeTab remove .${tw.activeLight} .${tw.activeDark} from the tab remove .${tw.textActiveLight} .${tw.textActiveDark} from the tab remove .border-b-0 from the tab add .${tw.inactiveLight} .${tw.inactiveDark} to the tab add .${tw.textInactiveLight} .${tw.textInactiveDark} to the tab end -- for loop end -- deactivateSiblings handler `export const tw = { container: 'tab-panel flex justify-left ', output: 'block text-left p-4 w-full overflow-y-scroll ' + 'bg-gray-100 dark:bg-gray-950 ', tabItem: 'z-10 tab-item select-none ' + 'font-semibold rounded-t-xl cursor-pointer ' + 'hover:bg-orange-100 dark:hover:bg-purple-900 ' + 'border-2 border-slate-600 ', activeLight: 'bg-gray-100', activeDark: 'dark:bg-gray-950', inactiveLight: 'bg-slate-300', inactiveDark: 'dark:bg-gray-800', textActiveLight: 'text-blue-950', textActiveDark: 'dark:text-orange-200', textInactiveLight: 'text-blue-900', textInactiveDark: 'dark:text-zinc-200'} ---import {twMerge} from 'tailwind-merge'
interface Props { defaultTabId?:string // The id of the tab to be activated by default maxHeight?:string // The maximum height of the panel content area}
const { defaultTabId = '0', maxHeight = 'max-h-[30rem]'} = Astro.props
export const tw = { container: 'tab-panel flex justify-left ', output: 'block text-left p-4 w-full overflow-y-scroll ' + 'bg-gray-100 dark:bg-gray-950 ', tabItem: 'z-10 tab-item select-none ' + 'font-semibold rounded-t-xl cursor-pointer ' + 'hover:bg-orange-100 dark:hover:bg-purple-900 ' + 'border-2 border-slate-600 ', activeLight: 'bg-gray-100', activeDark: 'dark:bg-gray-950', inactiveLight: 'bg-slate-300', inactiveDark: 'dark:bg-gray-800', textActiveLight: 'text-blue-950', textActiveDark: 'dark:text-orange-200', textInactiveLight: 'text-blue-900', textInactiveDark: 'dark:text-zinc-200'}
const hs = ` on load set defaultTab to #{${defaultTabId}} send activateTab(tab:defaultTab) to me end -- load handler
on activateTab(tab) remove .${tw.inactiveLight} .${tw.inactiveDark} from the tab remove .${tw.textInactiveLight} .${tw.textInactiveDark} from the tab add .${tw.activeLight} .${tw.activeDark} to the tab add .${tw.textActiveLight} .${tw.textActiveDark} to the tab add .border-b-0 to the tab send deactivateSiblings(activeTab:tab) to me end -- activateTab handler
on deactivateSiblings(activeTab) set allTabs to my children for tab in allTabs if tab is not the activeTab remove .${tw.activeLight} .${tw.activeDark} from the tab remove .${tw.textActiveLight} .${tw.textActiveDark} from the tab remove .border-b-0 from the tab add .${tw.inactiveLight} .${tw.inactiveDark} to the tab add .${tw.textInactiveLight} .${tw.textInactiveDark} to the tab end -- for loop end -- deactivateSiblings handler
`---
<div class={tw.container} hx-trigger="click" hx-swap="innerHTML transition:true" hx-target="next <output/>" script={hs}> <slot/></div><output class={tw.output + ' ' + maxHeight}/>TabPanelItem
Each visible Tab is implemented by an instance of the TabPanelItem component.
The essential detail is a button styled to appear as a tab by rounding the upper corners. Clicking the button emits a custom event which Hyperscript listens for in the parent TabPanel container.
Props
interface Props { id: string caption: string path: string trigger?: string }
const { id, caption, path, trigger = 'click' } = Astro.propsid
Each TabPanelItem requires a unique identifier to prevent collisions with others on the same page.
caption
The caption is a string displayed within the tab.
path
HTML markup is loaded from the backend via a custom endpoint specified by the path prop.
To implement this hyperComponent in your project, you will need to create a route which will respond with the html markup to be displayed in the content area of this control.
trigger
This prop takes the event(s) which will activate an individual tab.
The default value is ‘click’. You can override the default value with triggers such as mouseenter or load depending on the needs of your application. For multiple trigger events use a comma separated string.
Since only one tab can be activated at a time, be careful not to overload the trigger props so they interfere with each other.
Styling
import {tw} from './TabPanel.astro'Styling for each TabPanelItem is the essence of simplicity. All the styles are simply imported from the place where they are used which is the TabPanel parent container.
This unique strategy allows for rapid prototyping typical of Tailwind utility classes while also allowing access to these choices across multiple related components.
The TabPanel container uses most of these TW classes to switch the appearance of active and inactive tabs.
At the same time, exporting these styles and then importing them into the child component (TabPanelItem) shares the styling in such a manner that changes made in the parent are propogated to the children.
You could just destructure the properties of the tw constant that are used in the child component, but the coding syntax used above is just plain simple and stable.
Slots
There are no slots in this component
Events
The TabPanelItem only emits a single custom event when the <button> element is clicked.
activateTab(tab:me )
This custom event is emitted when the <button> element is clicked. The target of this event is the parent div.tab-panel which in turn listens for this event and switches the active Tab to the one passed as the argument.
Attributes
hx-trigger={trigger} hx-get={`${path}`}hx-trigger
The default trigger event (click) is set on the parent but can be overridden in each child TabPanelItem by passing a different event(s) to the trigger prop which is then referenced by the hx-trigger attribute in the markup.
hx-get
This attribute tells htmx which AJAX request to emit and the corresponding XHR verb (which is ‘GET’ in this case).
A custom endpoint is provided to the hx-get attribute via the path prop.
When the trigger event occurs, htmx sends an HTTP-GET request to the corresponding path and the resulting html markup is placed into the DOM according to the hx-target and hx-swap attributes on the parent TabPanel container which are available via inheritance.
Code
Explore the entire source code of the TabPanelItem component in the tabs below.
To utilize this component in your project, copy/paste the contents of the Everything panel into an .astro file in your src folder.
interface Props { id: string caption: string path: string trigger?: string }
const { id, caption, path, trigger = 'click' } = Astro.props<button id={id} class={tw.tabItem} hx-trigger={trigger} hx-get={`${path}`} script={hs}> {caption}</button>const hs = `init add .${tw.inactiveLight} .${tw.inactiveDark} to me add .${tw.textInactiveLight} .${tw.textInactiveDark} to meend
on click send activateTab(tab:me) to the closest <div.tab-panel/>`import {tw} from './TabPanel.astro'---
import {twMerge} from 'tailwind-merge'
interface Props { id: string // a unique identifier caption: string // a string displayed in the Tab path: string // the API route to be loaded when the Tab is active trigger?: string //one or more (comma separated) events which activate the Tab}
const { id, caption, path, trigger = 'click'} = Astro.props
import {tw} from './TabPanel.astro'
const hs = ` init add .${tw.inactiveLight} .${tw.inactiveDark} to me add .${tw.textInactiveLight} .${tw.textInactiveDark} to me end
on click send activateTab(tab:me) to the closest <div.tab-panel/>`---
<button id={id} class={tw.tabItem} hx-trigger={trigger} hx-get={`${path}`} script={hs}> {caption}</button>Usage
There are many situations where TabPanels are useful.
One common use case is illustrated by this very site where different source code blocks are presented in separate tabs.
For library documentation sites, TabPanels are frequently used to offer different language implementations of the same source code allowing the user to grab the code for their favorite language.