Originally published at cruip.com
--
In this second part of our article series, we will show you how to use the code from our first tutorial (creating animated and accessible tabs with Alpine.js) to build a reusable component for React and Next.js.
If you’ve read the first piece of this series, you already know how powerful tabs are in creating related/connected elements that differ in some way but are linked under the same content.
If you want to see how versatile tabs are at displaying similar but different information, take a look at some of our Tailwind CSS templates where we use this component:
A dark SaaS website template created to show off your startup or software.
A simple website template developed for displaying any app or idea.
A modern landing page template for startups created to shine a spotlight on your next startup idea.
Creating the component structure
Let’s start by creating a new file for the component called unconventional-tabs.tsx
. Note that we will be using the .tsx
extension instead of .jsx
to be able to use TypeScript and make the code safer and more reliable.
'use client'
import TabImage01 from '@/public/tabs-image-01.jpg'
import Tab0Image2 from '@/public/tabs-image-02.jpg'
import Tab0Image3 from '@/public/tabs-image-03.jpg'
import { StaticImageData } from 'next/image'
interface Tab {
title: string
img: StaticImageData
tag: string
excerpt: string
link: string
}
export default function UnconventionalTabs() {
const tabs: Tab[] = [
{
title: 'Lassen Peak',
img: TabImage01,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
{
title: 'Mount Shasta',
img: Tab0Image2,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
{
title: 'Eureka Peak',
img: Tab0Image3,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
]
return (
<></>
)
}
Great! As you noticed, we have defined the content of the tabs using an array and imported the necessary images for the tab panels. Each tab item is an object including title
, image
, tag
, excerpt
, and link
properties. In the next step, we’ll iterate over the array using a map()
loop to render each tab and its corresponding tab panel, thereby avoiding repetitive code.
Additionally, we have defined an interface called Tab
to define the types for each tab object’s property. This allows us to use TypeScript to verify that the data we pass to the component is correct, and provides us with an idea of the required data for the component.
Creating the component markup
Now let’s focus on what we will include in the return()
method. In the previous tutorial, we showed you how to use and handle ARIA roles and attributes to make the component fully accessible. In this case, instead of starting from scratch, we have decided to use the Tabs component provided by the Headless UI library. This way, we don’t have to worry about all the accessibility-related aspects since they are already excellently handled by the library.
'use client'
import { Fragment } from 'react'
import Image, { StaticImageData } from 'next/image'
import { Tab } from '@headlessui/react'
import { Caveat } from 'next/font/google'
import TabImage01 from '@/public/tabs-image-01.jpg'
import Tab0Image2 from '@/public/tabs-image-02.jpg'
import Tab0Image3 from '@/public/tabs-image-03.jpg'
const caveat = Caveat({
subsets: ['latin'],
variable: '--font-caveat',
display: 'swap'
})
interface Tab {
title: string
img: StaticImageData
tag: string
excerpt: string
link: string
}
export default function UnconventionalTabs() {
const tabs: Tab[] = [
{
title: 'Lassen Peak',
img: TabImage01,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
{
title: 'Mount Shasta',
img: Tab0Image2,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
{
title: 'Eureka Peak',
img: Tab0Image3,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
]
return (
<Tab.Group>
{({ selectedIndex }) => (
<div className={`${caveat.variable}`}>
{/* Buttons */}
<div className="flex justify-center">
<Tab.List className="max-[480px]:max-w-[180px] inline-flex flex-wrap justify-center bg-slate-200 rounded-[20px] p-1 mb-8 min-[480px]:mb-12">
{tabs.map((tab, index) => (
<Tab key={index} as={Fragment}>
<button
className={`flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none ui-focus-visible:outline-none ui-focus-visible:ring ui-focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out ${selectedIndex === index ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'}}`}>{tab.title}</button>
</Tab>
))}
</Tab.List>
</div>
{/* Tab panels */}
<Tab.Panels className="max-w-[640px] mx-auto">
<div className="relative flex flex-col">
{tabs.map((tab, index) => (
<Tab.Panel
key={index}
as={Fragment}
>
<article className="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring">
<figure className="min-[480px]:w-1/2 p-2">
<Image className="w-full h-[180px] min-[480px]:h-full object-cover rounded-lg" width="304" height="214" src={tab.img} alt={tab.title} />
</figure>
<div className="min-[480px]:w-1/2 flex flex-col justify-center p-5 pl-3">
<div className="flex justify-between mb-1">
<header>
<div className="font-caveat text-xl font-medium text-sky-500">{tab.tag}</div>
<h1 className="text-xl font-bold text-slate-900">{tab.title}</h1>
</header>
<button className="shrink-0 h-[30px] w-[30px] border border-slate-200 hover:border-slate-300 rounded-full shadow inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" aria-label="Like">
<svg className="fill-red-500" xmlns="http://www.w3.org/2000/svg" width="14" height="13">
<path d="M6.985 1.635C5.361.132 2.797.162 1.21 1.7A3.948 3.948 0 0 0 0 4.541a3.948 3.948 0 0 0 1.218 2.836l5.156 4.88a.893.893 0 0 0 1.223 0l5.165-4.886a3.925 3.925 0 0 0 .061-5.663C11.231.126 8.62.094 6.985 1.635Zm4.548 4.53-4.548 4.303-4.54-4.294a2.267 2.267 0 0 1 0-3.275 2.44 2.44 0 0 1 3.376 0c.16.161.293.343.398.541a.915.915 0 0 0 .766.409c.311 0 .6-.154.767-.409.517-.93 1.62-1.401 2.677-1.142 1.057.259 1.797 1.181 1.796 2.238a2.253 2.253 0 0 1-.692 1.63Z" />
</svg>
</button>
</div>
<div className="text-slate-500 text-sm line-clamp-3 mb-2">{tab.excerpt}</div>
<div className="text-right">
<a className="text-sm font-medium text-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-out" href={tab.link}>Read more -></a>
</div>
</div>
</article>
</Tab.Panel>
))}
</div>
</Tab.Panels>
</div>
)}
</Tab.Group>
)
}
The component is nearly complete and already fully accessible. We followed the documentation and added Tailwind CSS classes to give the component the desired style. But there’s no animation yet. Therefore, we will use the Transition component to create transitions between tab panels.
One more thing worth mentioning. We’ve installed the @headlessui/tailwindcss
package, and imported it as a plugin in our tailwind.config.js
file.
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
inter: ['var(--font-inter)', 'sans-serif'],
caveat: ['var(--font-caveat)', 'cursive'],
},
},
},
plugins: [require('@headlessui/tailwindcss')],
}
This package allows us to use some aditional Tailwind CSS utilities for styling the components based on their state. In particular, we are using ui-focus-visible:
to make the ring around the focused tab visible only when users are navigating with the keyboard.
Adding transitions between tab panels
Let’s import the Transition
component from Headless UI and use it to replace the article
tag.
'use client'
import { Fragment } from 'react'
import Image, { StaticImageData } from 'next/image'
import { Tab } from '@headlessui/react'
import { Transition } from '@headlessui/react'
import { Caveat } from 'next/font/google'
import TabImage01 from '@/public/tabs-image-01.jpg'
import Tab0Image2 from '@/public/tabs-image-02.jpg'
import Tab0Image3 from '@/public/tabs-image-03.jpg'
const caveat = Caveat({
subsets: ['latin'],
variable: '--font-caveat',
display: 'swap'
})
interface Tab {
title: string
img: StaticImageData
tag: string
excerpt: string
link: string
}
export default function UnconventionalTabs() {
const tabs: Tab[] = [
{
title: 'Lassen Peak',
img: TabImage01,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
{
title: 'Mount Shasta',
img: Tab0Image2,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
{
title: 'Eureka Peak',
img: Tab0Image3,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
]
return (
<Tab.Group>
{({ selectedIndex }) => (
<div className={`${caveat.variable}`}>
{/* Buttons */}
<div className="flex justify-center">
<Tab.List className="max-[480px]:max-w-[180px] inline-flex flex-wrap justify-center bg-slate-200 rounded-[20px] p-1 mb-8 min-[480px]:mb-12">
{tabs.map((tab, index) => (
<Tab key={index} as={Fragment}>
<button
className={`flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none ui-focus-visible:outline-none ui-focus-visible:ring ui-focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out ${selectedIndex === index ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'}}`}>{tab.title}</button>
</Tab>
))}
</Tab.List>
</div>
{/* Tab panels */}
<Tab.Panels className="max-w-[640px] mx-auto">
<div className="relative flex flex-col">
{tabs.map((tab, index) => (
<Tab.Panel
key={index}
as={Fragment}
static={true}
>
<Transition
as="article"
show={selectedIndex === index}
unmount={false}
className="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform order-first"
enterFrom="opacity-0 -translate-y-8"
enterTo="opacity-100 translate-y-0"
leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-12"
>
<figure className="min-[480px]:w-1/2 p-2">
<Image className="w-full h-[180px] min-[480px]:h-full object-cover rounded-lg" width="304" height="214" src={tab.img} alt={tab.title} />
</figure>
<div className="min-[480px]:w-1/2 flex flex-col justify-center p-5 pl-3">
<div className="flex justify-between mb-1">
<header>
<div className="font-caveat text-xl font-medium text-sky-500">{tab.tag}</div>
<h1 className="text-xl font-bold text-slate-900">{tab.title}</h1>
</header>
<button className="shrink-0 h-[30px] w-[30px] border border-slate-200 hover:border-slate-300 rounded-full shadow inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" aria-label="Like">
<svg className="fill-red-500" xmlns="http://www.w3.org/2000/svg" width="14" height="13">
<path d="M6.985 1.635C5.361.132 2.797.162 1.21 1.7A3.948 3.948 0 0 0 0 4.541a3.948 3.948 0 0 0 1.218 2.836l5.156 4.88a.893.893 0 0 0 1.223 0l5.165-4.886a3.925 3.925 0 0 0 .061-5.663C11.231.126 8.62.094 6.985 1.635Zm4.548 4.53-4.548 4.303-4.54-4.294a2.267 2.267 0 0 1 0-3.275 2.44 2.44 0 0 1 3.376 0c.16.161.293.343.398.541a.915.915 0 0 0 .766.409c.311 0 .6-.154.767-.409.517-.93 1.62-1.401 2.677-1.142 1.057.259 1.797 1.181 1.796 2.238a2.253 2.253 0 0 1-.692 1.63Z" />
</svg>
</button>
</div>
<div className="text-slate-500 text-sm line-clamp-3 mb-2">{tab.excerpt}</div>
<div className="text-right">
<a className="text-sm font-medium text-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-out" href={tab.link}>Read more -></a>
</div>
</div>
</Transition>
</Tab.Panel>
))}
</div>
</Tab.Panels>
</div>
)}
</Tab.Group>
)
}
We have set static={true}
in the <Tab.panel>
prop to preserve the tab panel and allow the transitions to work correctly.
However, the transitions still have a flickering effect because there is a moment when two panels are visible simultaneously, even though we have applied the absolute
class to the leaving one.
We can solve this issue similarly to what was done in the testimonial component. In short, we will dynamically set the height of the entering panel on the panel container element.
To do this, we will need to reference to the panel container element. Next, we will add a method called heightFix()
that calculates the height of the entering panel and set it on the panel container element. This method will be called within the beforeEnter
callback function provided by the Transition
component.
Here’s how the code will look like:
'use client'
import { useRef, useEffect, Fragment } from 'react'
import Image, { StaticImageData } from 'next/image'
import { Tab } from '@headlessui/react'
import { Transition } from '@headlessui/react'
import { Caveat } from 'next/font/google'
import TabImage01 from '@/public/tabs-image-01.jpg'
import Tab0Image2 from '@/public/tabs-image-02.jpg'
import Tab0Image3 from '@/public/tabs-image-03.jpg'
const caveat = Caveat({
subsets: ['latin'],
variable: '--font-caveat',
display: 'swap'
})
interface Tab {
title: string
img: StaticImageData
tag: string
excerpt: string
link: string
}
export default function UnconventionalTabs() {
const tabs: Tab[] = [
{
title: 'Lassen Peak',
img: TabImage01,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
{
title: 'Mount Shasta',
img: Tab0Image2,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
{
title: 'Eureka Peak',
img: Tab0Image3,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
]
const tabsRef = useRef<HTMLDivElement>(null)
const heightFix = () => {
if (tabsRef.current && tabsRef.current.parentElement) tabsRef.current.parentElement.style.height = `${tabsRef.current.clientHeight}px`
}
useEffect(() => {
heightFix()
}, [])
return (
<Tab.Group>
{({ selectedIndex }) => (
<div className={`${caveat.variable}`}>
{/* Buttons */}
<div className="flex justify-center">
<Tab.List className="max-[480px]:max-w-[180px] inline-flex flex-wrap justify-center bg-slate-200 rounded-[20px] p-1 mb-8 min-[480px]:mb-12">
{tabs.map((tab, index) => (
<Tab key={index} as={Fragment}>
<button
className={`flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none ui-focus-visible:outline-none ui-focus-visible:ring ui-focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out ${selectedIndex === index ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'}}`}>{tab.title}</button>
</Tab>
))}
</Tab.List>
</div>
{/* Tab panels */}
<Tab.Panels className="max-w-[640px] mx-auto">
<div className="relative flex flex-col" ref={tabsRef}>
{tabs.map((tab, index) => (
<Tab.Panel
key={index}
as={Fragment}
static={true}
>
<Transition
as="article"
show={selectedIndex === index}
unmount={false}
className="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform order-first"
enterFrom="opacity-0 -translate-y-8"
enterTo="opacity-100 translate-y-0"
leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-12"
beforeEnter={() => heightFix()}
>
<figure className="min-[480px]:w-1/2 p-2">
<Image className="w-full h-[180px] min-[480px]:h-full object-cover rounded-lg" width="304" height="214" src={tab.img} alt={tab.title} />
</figure>
<div className="min-[480px]:w-1/2 flex flex-col justify-center p-5 pl-3">
<div className="flex justify-between mb-1">
<header>
<div className="font-caveat text-xl font-medium text-sky-500">{tab.tag}</div>
<h1 className="text-xl font-bold text-slate-900">{tab.title}</h1>
</header>
<button className="shrink-0 h-[30px] w-[30px] border border-slate-200 hover:border-slate-300 rounded-full shadow inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" aria-label="Like">
<svg className="fill-red-500" xmlns="http://www.w3.org/2000/svg" width="14" height="13">
<path d="M6.985 1.635C5.361.132 2.797.162 1.21 1.7A3.948 3.948 0 0 0 0 4.541a3.948 3.948 0 0 0 1.218 2.836l5.156 4.88a.893.893 0 0 0 1.223 0l5.165-4.886a3.925 3.925 0 0 0 .061-5.663C11.231.126 8.62.094 6.985 1.635Zm4.548 4.53-4.548 4.303-4.54-4.294a2.267 2.267 0 0 1 0-3.275 2.44 2.44 0 0 1 3.376 0c.16.161.293.343.398.541a.915.915 0 0 0 .766.409c.311 0 .6-.154.767-.409.517-.93 1.62-1.401 2.677-1.142 1.057.259 1.797 1.181 1.796 2.238a2.253 2.253 0 0 1-.692 1.63Z" />
</svg>
</button>
</div>
<div className="text-slate-500 text-sm line-clamp-3 mb-2">{tab.excerpt}</div>
<div className="text-right">
<a className="text-sm font-medium text-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-out" href={tab.link}>Read more -></a>
</div>
</div>
</Transition>
</Tab.Panel>
))}
</div>
</Tab.Panels>
</div>
)}
</Tab.Group>
)
}
Making the component reusable
As the final step in this tutorial, we want to make the component reusable. Currently, the content of the tabs is hardcoded directly in the component. This means that if we want to display another set of tabs, we have to duplicate and modify the component.
As you may guess, it would be much more advantageous to have a single component that renders the tabs and that we can configure using data passed as props from the parent component.
To achieve this, let’s move the image imports and the array with the tab content to the parent component, and pass the data to our tabs component as a prop:
export const metadata = {
title: 'Unconventional Tabs - Cruip Tutorials',
description: 'Page description',
}
import TabImage01 from '@/public/tabs-image-01.jpg'
import Tab0Image2 from '@/public/tabs-image-02.jpg'
import Tab0Image3 from '@/public/tabs-image-03.jpg'
import UnconventionalTabs from '@/components/unconventional-tabs'
export default function UnconventionalTabsPage() {
const tabs = [
{
title: 'Lassen Peak',
img: TabImage01,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
{
title: 'Mount Shasta',
img: Tab0Image2,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
{
title: 'Eureka Peak',
img: Tab0Image3,
tag: 'Mountain',
excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
link: '#0'
},
]
return (
<main className="relative min-h-screen flex flex-col justify-center bg-white overflow-hidden">
<div className="w-full max-w-6xl mx-auto px-4 md:px-6 py-24">
<UnconventionalTabs tabs={tabs} />
</div>
</main>
)
}
Once that’s done, all that’s left is to pass the props to the tabs component like this:
function UnconventionalTabs({ tabs }: { tabs: Tab[] })
Et voilà ! The component is now complete, and it will look like this:
'use client'
import { useRef, useEffect, Fragment } from 'react'
import Image, { StaticImageData } from 'next/image'
import { Tab } from '@headlessui/react'
import { Transition } from '@headlessui/react'
import { Caveat } from 'next/font/google'
const caveat = Caveat({
subsets: ['latin'],
variable: '--font-caveat',
display: 'swap'
})
interface Tab {
title: string
img: StaticImageData
tag: string
excerpt: string
link: string
}
export default function UnconventionalTabs({ tabs }: { tabs: Tab[] }) {
const tabsRef = useRef<HTMLDivElement>(null)
const heightFix = () => {
if (tabsRef.current && tabsRef.current.parentElement) tabsRef.current.parentElement.style.height = `${tabsRef.current.clientHeight}px`
}
useEffect(() => {
heightFix()
}, [])
return (
<Tab.Group>
{({ selectedIndex }) => (
<div className={`${caveat.variable}`}>
{/* Buttons */}
<div className="flex justify-center">
<Tab.List className="max-[480px]:max-w-[180px] inline-flex flex-wrap justify-center bg-slate-200 rounded-[20px] p-1 mb-8 min-[480px]:mb-12">
{tabs.map((tab, index) => (
<Tab key={index} as={Fragment}>
<button
className={`flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none ui-focus-visible:outline-none ui-focus-visible:ring ui-focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out ${selectedIndex === index ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'}}`}>{tab.title}</button>
</Tab>
))}
</Tab.List>
</div>
{/* Tab panels */}
<Tab.Panels className="max-w-[640px] mx-auto">
<div className="relative flex flex-col" ref={tabsRef}>
{tabs.map((tab, index) => (
<Tab.Panel
key={index}
as={Fragment}
static={true}
>
<Transition
as="article"
show={selectedIndex === index}
unmount={false}
className="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform order-first"
enterFrom="opacity-0 -translate-y-8"
enterTo="opacity-100 translate-y-0"
leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-12"
beforeEnter={() => heightFix()}
>
<figure className="min-[480px]:w-1/2 p-2">
<Image className="w-full h-[180px] min-[480px]:h-full object-cover rounded-lg" width="304" height="214" src={tab.img} alt={tab.title} />
</figure>
<div className="min-[480px]:w-1/2 flex flex-col justify-center p-5 pl-3">
<div className="flex justify-between mb-1">
<header>
<div className="font-caveat text-xl font-medium text-sky-500">{tab.tag}</div>
<h1 className="text-xl font-bold text-slate-900">{tab.title}</h1>
</header>
<button className="shrink-0 h-[30px] w-[30px] border border-slate-200 hover:border-slate-300 rounded-full shadow inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" aria-label="Like">
<svg className="fill-red-500" xmlns="http://www.w3.org/2000/svg" width="14" height="13">
<path d="M6.985 1.635C5.361.132 2.797.162 1.21 1.7A3.948 3.948 0 0 0 0 4.541a3.948 3.948 0 0 0 1.218 2.836l5.156 4.88a.893.893 0 0 0 1.223 0l5.165-4.886a3.925 3.925 0 0 0 .061-5.663C11.231.126 8.62.094 6.985 1.635Zm4.548 4.53-4.548 4.303-4.54-4.294a2.267 2.267 0 0 1 0-3.275 2.44 2.44 0 0 1 3.376 0c.16.161.293.343.398.541a.915.915 0 0 0 .766.409c.311 0 .6-.154.767-.409.517-.93 1.62-1.401 2.677-1.142 1.057.259 1.797 1.181 1.796 2.238a2.253 2.253 0 0 1-.692 1.63Z" />
</svg>
</button>
</div>
<div className="text-slate-500 text-sm line-clamp-3 mb-2">{tab.excerpt}</div>
<div className="text-right">
<a className="text-sm font-medium text-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-out" href={tab.link}>Read more -></a>
</div>
</div>
</Transition>
</Tab.Panel>
))}
</div>
</Tab.Panels>
</div>
)}
</Tab.Group>
)
}
Conclusions
We’ve reached the end of the second part. If you want to learn how to build this component in Alpine.js and Vue, check out the first and third parts: