A row of four tabs drawn as keyboard keys, above a panel listing shortcuts for a code editor. Each key is a white cap on a dark base. Hovering an unselected key tips the cap up from its top right corner so the base shows underneath; the selected key sits flat with an orange outline and an orange line under its base, like a key held down. Choosing a tab fades the panel and brings its four rows in one after another. The preview steps through the tabs until a visitor picks one. The keys wrap onto two rows on narrow phones and the panel keeps a fixed height.
// Adapted from Animata (https://animata.design/docs/tabs/shift-tabs)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
const tabs = [
{
label: "Editor",
shortcuts: [
{ keys: ["Ctrl", "D"], does: "Select the next match" },
{ keys: ["Alt", "↑"], does: "Move the line up" },
{ keys: ["Ctrl", "/"], does: "Comment or uncomment" },
{ keys: ["Ctrl", "Shift", "K"], does: "Delete the line" },
],
},
{
label: "Search",
shortcuts: [
{ keys: ["Ctrl", "P"], does: "Open a file by name" },
{ keys: ["Ctrl", "Shift", "F"], does: "Search every file" },
{ keys: ["F3"], does: "Jump to the next result" },
{ keys: ["Ctrl", "G"], does: "Go to a line number" },
],
},
{
label: "Terminal",
shortcuts: [
{ keys: ["Ctrl", "`"], does: "Show or hide the terminal" },
{ keys: ["Ctrl", "Shift", "5"], does: "Split the terminal" },
{ keys: ["Ctrl", "L"], does: "Clear the screen" },
{ keys: ["Ctrl", "C"], does: "Stop the running task" },
],
},
{
label: "Git",
shortcuts: [
{ keys: ["Ctrl", "Shift", "G"], does: "Open source control" },
{ keys: ["Ctrl", "Enter"], does: "Commit staged changes" },
{ keys: ["Alt", "B"], does: "Switch branch" },
{ keys: ["Alt", "Z"], does: "Undo the last commit" },
],
},
];
export default function TiltedKeycapTabsWithPressedActiveState() {
const reduce = useReducedMotion();
const [active, setActive] = useState(0);
const [auto, setAuto] = useState(true);
const refs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
if (!auto || reduce) return;
const timer = window.setInterval(() => setActive((current) => (current + 1) % tabs.length), 2600);
return () => window.clearInterval(timer);
}, [auto, reduce]);
const choose = (index: number) => {
setAuto(false);
setActive(index);
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (!step) return;
event.preventDefault();
const next = (active + step + tabs.length) % tabs.length;
choose(next);
refs.current[next]?.focus();
};
return (
<section className="bg-orange-50 px-4 py-14 sm:px-6 sm:py-20">
<div className="mx-auto max-w-2xl">
<p className="text-center font-mono text-sm font-semibold uppercase tracking-widest text-orange-700">Cheat sheet</p>
<h2 className="mt-3 text-center text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl">Sixteen shortcuts worth learning.</h2>
<div role="tablist" aria-label="Shortcut groups" onKeyDown={onKeyDown} className="mt-9 flex flex-wrap items-center justify-center gap-3 sm:gap-4">
{tabs.map((tab, index) => {
const selected = index === active;
// While it runs on its own, the key that is up next is shown tipped, the same as a hover.
const hinted = auto && !reduce && index === (active + 1) % tabs.length;
return (
<button
key={tab.label}
ref={(el) => {
refs.current[index] = el;
}}
type="button"
role="tab"
id={`keycap-tab-${index}`}
aria-selected={selected}
aria-controls="keycap-panel"
tabIndex={selected ? 0 : -1}
onClick={() => choose(index)}
className={`group rounded-lg border-b-2 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 focus-visible:ring-offset-4 focus-visible:ring-offset-orange-50 active:scale-95 motion-reduce:active:scale-100 ${
selected ? "border-b-orange-600 bg-zinc-900" : hinted ? "border-b-transparent bg-zinc-900" : "border-b-transparent bg-transparent hover:bg-zinc-900"
}`}
>
{/* The cap. Unselected caps hinge up from their top right corner and show the dark base. */}
<span
className={`flex h-11 select-none items-center justify-center rounded-md border-2 bg-white px-4 font-mono text-sm font-semibold transition-transform duration-200 ease-out motion-reduce:transition-none sm:px-5 ${
selected ? "rotate-0 border-orange-600 text-orange-700" : `origin-top-right border-zinc-300 text-zinc-800 group-hover:rotate-6 motion-reduce:group-hover:rotate-0 ${hinted ? "rotate-6" : ""}`
}`}
>
{tab.label}
</span>
</button>
);
})}
</div>
<div
id="keycap-panel"
role="tabpanel"
aria-labelledby={`keycap-tab-${active}`}
className="mt-7 h-[292px] overflow-hidden rounded-2xl border-2 border-zinc-900 bg-white shadow-[6px_6px_0_0_#18181b]"
>
<AnimatePresence mode="wait" initial={false}>
<motion.ul
key={active}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: 0.18 }}
className="divide-y divide-zinc-200"
>
{tabs[active].shortcuts.map((shortcut, row) => (
<motion.li
key={shortcut.does}
initial={reduce ? false : { opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 * row, duration: 0.25 }}
className="flex h-[72px] items-center justify-between gap-4 px-5"
>
<span className="text-sm font-medium text-zinc-800 sm:text-base">{shortcut.does}</span>
<span className="flex shrink-0 items-center gap-1.5">
{shortcut.keys.map((key) => (
<kbd key={key} className="flex h-8 min-w-8 items-center justify-center rounded-md border border-b-2 border-zinc-300 bg-zinc-50 px-2 font-mono text-xs font-semibold text-zinc-800">
{key}
</kbd>
))}
</span>
</motion.li>
))}
</motion.ul>
</AnimatePresence>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/tilted-keycap-tabs-with-pressed-active-state.json
The button is the base and a span inside it is the cap. On hover the button's background turns dark and the span rotates six degrees around its top right corner, so its lower left edge lifts away and the dark button shows in the gap.
The selected cap has `rotate-0` and no hover rotation, and its base is always dark with a coloured bottom border. It reads as pressed in, while the others read as loose keys you can flick.
Only for the panel. The tabs use Tailwind transitions. If you remove `AnimatePresence` and render the list directly, the component needs no animation library.
Lift `active` into the parent and pass it down with a setter. The tabs and the panel both read that one value, so they stay in step.
Yes. Put a 16 pixel icon before the label inside the cap span and add `gap-2`. The cap is a flex row, so it stays centred.
Adapted from Shift Tabs in Animata, used under its open-source licence. Licences and credits
Related: Gooey Tabs with Liquid Merging Active Tab, Animated Tabs with Sliding Highlight and Crossfade, Direction Aware Tabs with Sliding Panels.
More tabs and navigation or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.