Phototab
Tabs where the panel is a photograph rather than a paragraph. A small icon bar sits at the bottom edge; on desktop it waits just outside the frame and slides up when the pointer or the keyboard reaches the component, so the image gets the whole square until you ask for navigation.
Hovering a tab grows a pill behind it, measured from the real trigger rectangle, so it matches the icon spacing instead of guessing a width. The selected tab keeps a solid surface underneath, which means the current panel is readable at a glance even when the pill is elsewhere.
Usage
Each tab needs a name, an image and an optional icon. Names double as the alt text and as the slug for the generated tab ids, so keep them unique.
<template>
<div class="flex w-full justify-center py-6">
<MPhototab
default-tab="Coast"
:height="420"
:tabs="tabs"
/>
</div>
</template>
<script setup lang="ts">
const tabs = [
{ name: 'Mountains', icon: 'i-lucide-mountain', image: 'https://picsum.photos/seed/mountains/800/800' },
{ name: 'Coast', icon: 'i-lucide-waves', image: 'https://picsum.photos/seed/coast/800/800' },
{ name: 'Forest', icon: 'i-lucide-trees', image: 'https://picsum.photos/seed/forest/800/800' },
{ name: 'City', icon: 'i-lucide-building-2', image: 'https://picsum.photos/seed/city/800/800' },
]
</script>
The component is square by design: height sets the box, and the aspect ratio
derives the width, so a row of tabs never changes the layout when you switch
panels.
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
tabs | PhototabTab[] | - | Tabs to display. Each entry takes name, image and an optional icon
(Iconify name). |
defaultTab | string | - | Name of the tab selected on mount. Defaults to the first tab. |
height | number | 400 | Height in pixels. The width follows from the square aspect ratio. |
tabListClassName | string | - | Extra classes for the tab bar. |
tabTriggerClassName | string | - | Extra classes for each tab trigger. |
imageClassName | string | - | Extra classes for the image. |
class | string | - | Extra classes for the root. |
Accessibility
- The bar is a
role="tablist"witharia-label, each trigger is arole="tab", and each panel is arole="tabpanel"linked back witharia-labelledby. - Tab ids are slugged from the names, so a multi-word label such as
Tab 1still produces anaria-controlsthat resolves to a real element. - Triggers use roving
tabindex: only the active tab is in the tab order, and Left/Right wrap around while Home/End jump to the ends. Selection follows focus. - The visible content of a trigger is an icon, so each one carries an
aria-labeland a visually hidden name. - The bar slides in on hover, but also on
:focus-within, so keyboard users are never left with an off-screen control. - Images use the tab name as alt text, and the selected panel is the only one displayed.
- Focus is a two-pixel ring offset from the trigger, independent of the active surface colour.
Performance
- Images load lazily and only the selected panel is displayed; the others stay in the DOM but are not painted.
- The hover pill measures two rectangles on pointer enter, then animates offsets and size with a spring. Nothing is measured during the animation or on scroll.
- Tabs are keyed by their slug, so switching panels does not remount the tab bar.
- The component has no timers, no observers and no animation loop of its own.
- Under
prefers-reduced-motionthe pill is skipped entirely and the bar moves without a transition.