Tabs
Navigation interface that organizes content into sections.
TailwindCSS
Vanilla JS
Account
Make changes to your account here. Click save when you are done.
Password
Change your password here. After saving, you will be logged out.
package showcase
import (
"github.com/axzilla/templui/internal/components/button"
"github.com/axzilla/templui/internal/components/card"
"github.com/axzilla/templui/internal/components/input"
"github.com/axzilla/templui/internal/components/tabs"
)
templ TabsDefault() {
@tabs.Tabs(tabs.Props{
ID: "account-tabs",
}) {
@tabs.List(tabs.ListProps{
Class: "w-full max-w-xs",
}) {
@tabs.Trigger(tabs.TriggerProps{
Value: "account",
IsActive: true,
}) {
Account
}
@tabs.Trigger(tabs.TriggerProps{
Value: "password",
}) {
Password
}
}
<div class="w-full max-w-xs mt-2">
@tabs.Content(tabs.ContentProps{
Value: "account",
IsActive: true,
}) {
@AccountTab()
}
@tabs.Content(tabs.ContentProps{
Value: "password",
}) {
@PasswordTab()
}
</div>
}
}
templ AccountTab() {
@card.Card() {
@card.Header() {
@card.Title() {
Account
}
@card.Description() {
Make changes to your account here. Click save when you are done.
}
}
@card.Content() {
<div class="flex flex-col gap-4">
@input.Input(input.Props{
Type: input.TypeText,
Placeholder: "Name",
ID: "name",
Value: "John Doe",
})
@input.Input(input.Props{
Type: input.TypeEmail,
Placeholder: "Email",
ID: "email",
Value: "john.doe@example.com",
})
</div>
}
@card.Footer() {
@button.Button() {
Save changes
}
}
}
}
templ PasswordTab() {
@card.Card() {
@card.Header() {
@card.Title() {
Password
}
@card.Description() {
Change your password here. After saving, you will be logged out.
}
}
@card.Content() {
<div class="flex flex-col gap-4">
@input.Input(input.Props{
Type: input.TypePassword,
Placeholder: "Current Password",
ID: "current_password",
})
@input.Input(input.Props{
Type: input.TypePassword,
Placeholder: "New Password",
ID: "new_password",
})
</div>
}
@card.Footer() {
@button.Button() {
Save password
}
}
}
}
Installation
templui add tabs
Copy and paste the following code into your project:
package tabs import ( "context" "github.com/axzilla/templui/internal/utils" ) type Props struct { ID string Class string Attributes templ.Attributes } type ListProps struct { ID string Class string Attributes templ.Attributes } type TriggerProps struct { ID string Class string Attributes templ.Attributes Value string IsActive bool TabsID string } type ContentProps struct { ID string Class string Attributes templ.Attributes Value string IsActive bool TabsID string } templ Tabs(props ...Props) { @Script() {{ var p Props }} if len(props) > 0 { {{ p = props[0] }} } {{ tabsID := p.ID }} if tabsID == "" { {{ tabsID = utils.RandomID() }} } <div if p.ID != "" { id={ p.ID } } class={ utils.TwMerge("relative", p.Class) } data-tabs data-tabs-id={ tabsID } { p.Attributes... } > {{ ctx = context.WithValue(ctx, "tabsId", tabsID) }} { children... } </div> } templ List(props ...ListProps) { {{ var p ListProps }} if len(props) > 0 { {{ p = props[0] }} } {{ tabsID := IDFromContext(ctx) }} <div if p.ID != "" { id={ p.ID } } class={ utils.TwMerge( "relative flex items-center justify-center h-10 p-1 rounded-lg select-none bg-muted text-muted-foreground", p.Class, ), } { p.Attributes... } > { children... } <div data-tabs-marker data-tabs-id={ tabsID } class="absolute left-0 z-10 h-full duration-300 ease-out" > <div class="w-full h-full bg-background rounded-md shadow-xs"></div> </div> </div> } templ Trigger(props ...TriggerProps) { {{ var p TriggerProps }} if len(props) > 0 { {{ p = props[0] }} } {{ tabsID := p.TabsID }} if tabsID == "" { {{ tabsID = IDFromContext(ctx) }} } if p.Value == "" { <span class="text-xs text-destructive">Error: Tab Trigger missing required 'Value' attribute.</span> } <button if p.ID != "" { id={ p.ID } } type="button" class={ utils.TwMerge( "relative z-20 flex-1 inline-flex items-center justify-center h-8 px-3 text-sm font-medium transition-all rounded-md cursor-pointer whitespace-nowrap hover:text-foreground", p.Class, ), } data-tabs-trigger data-tabs-id={ tabsID } data-tabs-value={ p.Value } data-state={ utils.IfElse(p.IsActive, "active", "inactive") } { p.Attributes... } > { children... } </button> } templ Content(props ...ContentProps) { {{ var p ContentProps }} if len(props) > 0 { {{ p = props[0] }} } {{ tabsID := p.TabsID }} if tabsID == "" { {{ tabsID = IDFromContext(ctx) }} } if p.Value == "" { <span class="text-xs text-destructive">Error: Tab Content missing required 'Value' attribute.</span> return templ.NopComponent } <div if p.ID != "" { id={ p.ID } } class={ utils.TwMerge( "relative", utils.If(!p.IsActive, "hidden"), p.Class, ), } data-tabs-content data-tabs-id={ tabsID } data-tabs-value={ p.Value } data-state={ utils.IfElse(p.IsActive, "active", "inactive") } { p.Attributes... } > { children... } </div> } func IDFromContext(ctx context.Context) string { if tabsID, ok := ctx.Value("tabsId").(string); ok { return tabsID } return "" } var handle = templ.NewOnceHandle() templ Script() { @handle.Once() { <script defer nonce={ templ.GetNonce(ctx) }> (function() { // IIFE function initTabs(container) { if (container.hasAttribute('data-initialized')) return; container.setAttribute('data-initialized', 'true'); const tabsId = container.dataset.tabsId; if (!tabsId) return; const triggers = Array.from(container.querySelectorAll(`[data-tabs-trigger][data-tabs-id="${tabsId}"]`)); const contents = Array.from(container.querySelectorAll(`[data-tabs-content][data-tabs-id="${tabsId}"]`)); const marker = container.querySelector(`[data-tabs-marker][data-tabs-id="${tabsId}"]`); function updateMarker(activeTrigger) { if (!marker || !activeTrigger) return; marker.style.width = activeTrigger.offsetWidth + 'px'; marker.style.height = activeTrigger.offsetHeight + 'px'; marker.style.left = activeTrigger.offsetLeft + 'px'; } function setActiveTab(value) { let activeTrigger = null; for (const trigger of triggers) { const isActive = trigger.dataset.tabsValue === value; trigger.dataset.state = isActive ? "active" : "inactive"; trigger.classList.toggle('text-foreground', isActive); trigger.classList.toggle('bg-background', isActive); trigger.classList.toggle('shadow-xs', isActive); if (isActive) activeTrigger = trigger; } for (const content of contents) { const isActive = content.dataset.tabsValue === value; content.dataset.state = isActive ? "active" : "inactive"; content.classList.toggle('hidden', !isActive); } updateMarker(activeTrigger); } const defaultTrigger = triggers.find(t => t.dataset.state === 'active') || triggers[0]; if (defaultTrigger) { setActiveTab(defaultTrigger.dataset.tabsValue); } for (const trigger of triggers) { trigger.addEventListener('click', () => { setActiveTab(trigger.dataset.tabsValue); }); } } function initAllComponents(root = document) { if (root instanceof Element && root.matches('[data-tabs]')) { initTabs(root); } for (const tabs of root.querySelectorAll('[data-tabs]:not([data-initialized])')) { initTabs(tabs); } } const handleHtmxSwap = (event) => { const target = event.detail.elt if (target instanceof Element) { requestAnimationFrame(() => initAllComponents(target)); } }; initAllComponents(); document.addEventListener('DOMContentLoaded', () => initAllComponents()); document.body.addEventListener('htmx:afterSwap', handleHtmxSwap); document.body.addEventListener('htmx:oobAfterSwap', handleHtmxSwap); })(); // End of IIFE </script> } }
Update the import paths to match your project setup.