uselayouts
componentaccordionossource: ok · hit
AccordionOS
Interactive accordion showcase with animated content, image transitions, hover effects, keyboard navigation, autoplay, and responsive design.
npx shadcn add "https://uselayouts.com/r/accordionos.json"free
Where it goes
kanban
Interaction
accordion
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files1 · source cached 10/9/2026, 11:56:17 PM
registry/default/example/accordionos.tsx
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
// ==========================================
// 1. HYDRATION-SAFE TOOLTIP COMPONENTS
// ==========================================
export function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
export function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
export function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
suppressHydrationWarning
{...props}
/>
);
}
export function TooltipContent({
className = "",
sideOffset = 4,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={`z-50 overflow-hidden rounded-md bg-neutral-900 px-3 py-1.5 text-xs text-neutral-50 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 ${className}`}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
// ==========================================
// 2. ACTION BUTTON COMPONENT
// ==========================================
export interface ActionButtonProps {
label: string;
active?: boolean;
disabled?: boolean;
onClick?: () => void;
icon?: React.ReactNode;
children?: React.ReactNode;
}
export function ActionButton({
label,
active = false,
disabled = false,
onClick,
icon,
children,
}: ActionButtonProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<button
type="button"
disabled={disabled}
suppressHydrationWarning
className="inline-flex size-9 items-center justify-center rounded-lg border-0 bg-transparent text-neutral-600 dark:text-neutral-300 disabled:opacity-40"
>
{icon || children}
</button>
);
}
return (
<Tooltip>
<TooltipTrigger asChild suppressHydrationWarning>
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-label={label}
aria-pressed={active}
suppressHydrationWarning
className={`inline-flex size-9 items-center justify-center rounded-lg border-0 transition-colors ${
active
? "bg-neutral-900 text-white dark:bg-white dark:text-neutral-900"
: "bg-transparent text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
} disabled:pointer-events-none disabled:opacity-40`}
>
{icon || children}
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{label}</p>
</TooltipContent>
</Tooltip>
);
}
// ==========================================
// 3. ACCORDION OS COMPONENT
// ==========================================
export interface AccordionItem {
id?: string | number;
title: string;
badge?: string;
claim: string;
image: string;
alt?: string;
}
export interface AccordionOSProps {
items?: AccordionItem[];
staticImage?: string;
defaultActiveIndex?: number | null;
className?: string;
containerHeight?: number | string;
containerWidth?: number | string;
autoPlay?: boolean;
autoPlayInterval?: number;
pauseOnHover?: boolean;
onItemChange?: (index: number | null) => void;
}
const DEFAULT_ITEMS: AccordionItem[] = [
{
id: 1,
title: "Centering",
badge: "01",
claim:
"Push the clay into the wheel's dead center. Until it stops wobbling, nothing else you do will hold.",
image: "https://images.unsplash.com/photo-1578662996442-48f60103fc96?auto=format&fit=crop&w=1200&q=80",
alt: "Hands centering wet clay on a pottery wheel",
},
{
id: 2,
title: "Pulling Walls",
badge: "02",
claim:
"Two fingers inside, one outside. Pull slow and even—the wall rises or it collapses.",
image: "https://images.unsplash.com/photo-1753164726043-31e583f8a9b8?auto=format&fit=crop&w=1200&q=80",
alt: "Potter pulling walls on a bowl at the wheel",
},
{
id: 3,
title: "Glaze Prep",
badge: "03",
claim:
"Oxides mixed in small bowls, tested on tile shards. The kiln decides if your recipe was right.",
image: "https://images.unsplash.com/photo-1771523351104-03d9c2cc347c?auto=format&fit=crop&w=1200&q=80",
alt: "Bowls of colorful ceramic glazes on a studio table",
},
{
id: 4,
title: "Trimming",
badge: "04",
claim:
"Foot ring cut while the bowl is still leather-hard. One cut too deep and months of work tips into the slop bucket.",
image: "https://images.unsplash.com/photo-1753164726182-4037e5b3bcaa?auto=format&fit=crop&w=1200&q=80",
alt: "Potter trimming a wide ceramic bowl in the studio",
},
{
id: 5,
title: "Finished Ware",
badge: "05",
claim:
"Cooling crackle, lids that fit, handles thick enough for hot tea. The kiln signed off.",
image: "https://images.unsplash.com/photo-1771830937026-3e3474fdd947?auto=format&fit=crop&w=1200&q=80",
alt: "Handmade ceramic bowls and vases on display",
},
];
const DEFAULT_STATIC_IMAGE = "https://images.unsplash.com/photo-1610701596007-11502861dcfa?auto=format&fit=crop&w=1200&q=80";
const springPhysics = {
type: "spring" as const,
stiffness: 340,
damping: 28,
mass: 0.8,
};
const bouncySpring = {
type: "spring" as const,
stiffness: 400,
damping: 22,
};
export function AccordionOS({
items = DEFAULT_ITEMS,
staticImage = DEFAULT_STATIC_IMAGE,
defaultActiveIndex = 0,
className = "",
containerHeight = 520,
containerWidth = 840,
autoPlay = true,
autoPlayInterval = 3500,
pauseOnHover = true,
onItemChange,
}: AccordionOSProps) {
const [activeIndex, setActiveIndex] = useState<number | null>(defaultActiveIndex);
const [isHovered, setIsHovered] = useState<boolean>(false);
const handleToggle = useCallback(
(index: number) => {
setActiveIndex((prev) => {
const next = prev === index ? null : index;
onItemChange?.(next);
return next;
});
},
[onItemChange]
);
const handlePrev = useCallback(() => {
setActiveIndex((prev) => {
if (prev === null) return 0;
const next = prev > 0 ? prev - 1 : items.length - 1;
onItemChange?.(next);
return next;
});
}, [items.length, onItemChange]);
const handleNext = useCallback(() => {
setActiveIndex((prev) => {
if (prev === null) return 0;
const next = prev < items.length - 1 ? prev + 1 : 0;
onItemChange?.(next);
return next;
});
}, [items.length, onItemChange]);
// Automatic slide changing (AutoPlay)
useEffect(() => {
if (!autoPlay || (pauseOnHover && isHovered)) return;
const interval = setInterval(() => {
handleNext();
}, autoPlayInterval);
return () => clearInterval(interval);
}, [autoPlay, autoPlayInterval, pauseOnHover, isHovered, handleNext]);
// Global Keyboard Navigation (ArrowUp, ArrowDown, W/S, 1-5, Escape)
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea" || (e.target as HTMLElement)?.isContentEditable) {
return;
}
if (e.key === "ArrowDown" || e.key === "ArrowRight" || e.key.toLowerCase() === "s") {
e.preventDefault();
handleNext();
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft" || e.key.toLowerCase() === "w") {
e.preventDefault();
handlePrev();
} else if (e.key === "Escape") {
e.preventDefault();
setActiveIndex(null);
onItemChange?.(null);
} else if (["1", "2", "3", "4", "5"].includes(e.key)) {
const num = parseInt(e.key, 10) - 1;
if (num < items.length) {
e.preventDefault();
setActiveIndex(num);
onItemChange?.(num);
}
}
};
window.addEventListener("keydown", handleGlobalKeyDown);
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
}, [handleNext, handlePrev, items.length, onItemChange]);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`accordion-os-root ${className}`}
style={{
width: typeof containerWidth === "number" ? `${containerWidth}px` : containerWidth,
maxWidth: "100%",
}}
>
<motion.div
className="accordion-os-card"
style={{
height: typeof containerHeight === "number" ? `${containerHeight}px` : containerHeight,
}}
initial={{ opacity: 0, scale: 0.96, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
>
{/* Left Interactive Column */}
<div className="accordion-os-sidebar">
{/* Accordion Items */}
<div className="accordion-os-items-stack">
{items.map((item, index) => {
const isOpen = activeIndex === index;
return (
<motion.div
key={item.id ?? index}
layout
transition={springPhysics}
onClick={() => handleToggle(index)}
tabIndex={0}
role="button"
aria-expanded={isOpen}
whileHover={{ scale: isOpen ? 1.01 : 1.03, x: 3 }}
whileTap={{ scale: 0.98 }}
className={`accordion-os-pill ${isOpen ? "accordion-os-pill-open" : "accordion-os-pill-closed"}`}
>
<div className="accordion-os-title-stack">
<motion.div
className="accordion-os-icon-btn"
animate={{
backgroundColor: isOpen ? "rgba(2, 2, 2, 0.9)" : "rgba(255, 255, 255, 0.8)",
color: isOpen ? "#ffffff" : "rgb(2, 2, 2)",
borderColor: isOpen ? "rgba(0, 0, 0, 0.8)" : "rgba(2, 2, 2, 0.2)",
}}
whileHover={{ scale: 1.15, rotate: isOpen ? -45 : 90 }}
whileTap={{ scale: 0.9 }}
transition={bouncySpring}
>
<motion.svg
viewBox="0 0 24 24"
className="accordion-os-svg-icon"
animate={{ rotate: isOpen ? 135 : 0 }}
transition={bouncySpring}
>
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</motion.svg>
</motion.div>
<div className="accordion-os-title-wrapper">
<span className="accordion-os-title-text">{item.title}</span>
{isOpen && item.badge ? (
<span className="accordion-os-pill-badge accordion-os-pill-badge-active">
{item.badge}
</span>
) : null}
</div>
</div>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key="claim-content"
initial={{ opacity: 0, height: 0, marginTop: 0, filter: "blur(4px)" }}
animate={{
opacity: 1,
height: "auto",
marginTop: 12,
filter: "blur(0px)",
transition: {
height: { duration: 0.4, ease: [0.16, 1, 0.3, 1] },
opacity: { duration: 0.3, delay: 0.08 },
filter: { duration: 0.3 },
},
}}
exit={{
opacity: 0,
height: 0,
marginTop: 0,
filter: "blur(4px)",
transition: {
height: { duration: 0.28, ease: [0.16, 1, 0.3, 1] },
opacity: { duration: 0.18 },
},
}}
className="accordion-os-claim-container"
>
<motion.p
initial={{ y: 8 }}
animate={{ y: 0 }}
transition={{ duration: 0.3, delay: 0.05 }}
className="accordion-os-claim-text"
>
{item.claim}
</motion.p>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
{/* Bottom Floating Navigation Deck */}
<div className="accordion-os-bottom-deck">
<AnimatePresence>
{activeIndex !== null && (
<motion.div
initial={{ opacity: 0, y: 15, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 15, scale: 0.9 }}
transition={bouncySpring}
className="accordion-os-nav-stack"
>
<button
type="button"
onClick={handlePrev}
aria-label="Previous (Up Arrow)"
className="accordion-os-nav-btn"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className="accordion-os-nav-svg">
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
<div className="accordion-os-counter">
<span>{String((activeIndex ?? 0) + 1).padStart(2, "0")}</span>
<span className="accordion-os-counter-divider">/</span>
<span>{String(items.length).padStart(2, "0")}</span>
</div>
<button
type="button"
onClick={handleNext}
aria-label="Next (Down Arrow)"
className="accordion-os-nav-btn"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className="accordion-os-nav-svg">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Persistent Pre-Decoded Instant GPU Layers */}
<div className="accordion-os-visual-wrapper">
{/* Static Default Image */}
<motion.div
className="accordion-os-image-layer"
initial={false}
animate={{
opacity: activeIndex === null ? 1 : 0,
scale: activeIndex === null ? 1 : 1.05,
zIndex: activeIndex === null ? 2 : 1,
}}
transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
>
<img
src={staticImage}
alt="Handmade ceramic vessels on a studio shelf"
className="accordion-os-image"
loading="eager"
decoding="async"
/>
</motion.div>
{/* 5 Slide Images */}
{items.map((item, idx) => {
const isActive = activeIndex === idx;
return (
<motion.div
key={item.id ?? idx}
className="accordion-os-image-layer"
initial={false}
animate={{
opacity: isActive ? 1 : 0,
scale: isActive ? 1 : 1.05,
zIndex: isActive ? 3 : 1,
}}
transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
>
<img
src={item.image}
alt={item.alt || item.title}
className="accordion-os-image"
loading="eager"
decoding="async"
/>
</motion.div>
);
})}
</div>
</motion.div>
{/* Embedded Component Styles */}
<style dangerouslySetInnerHTML={{ __html: `
.accordion-os-root {
box-sizing: border-box;
margin: 0 auto;
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
user-select: none;
perspective: 1200px;
container-type: inline-size;
}
.accordion-os-card {
position: relative;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: stretch;
background-color: #f6f6f8;
border-radius: 36px;
padding: 24px;
overflow: hidden;
width: 100%;
box-sizing: border-box;
}
.accordion-os-sidebar {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
width: 290px;
height: 100%;
flex-shrink: 0;
pointer-events: auto;
}
.accordion-os-items-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
width: 100%;
z-index: 10;
}
.accordion-os-pill {
width: fit-content;
max-width: 290px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
background-color: rgba(245, 245, 247, 0.72);
border-radius: 22px;
padding: 13px 18px;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.7);
transition: background-color 0.25s ease, border-color 0.25s ease;
outline: none;
box-sizing: border-box;
}
.accordion-os-pill:hover {
border-color: rgba(0, 0, 0, 0.12);
background-color: rgba(255, 255, 255, 0.92);
}
.accordion-os-pill:focus-visible {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);
}
.accordion-os-pill-open {
width: 290px;
background-color: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.1);
}
.accordion-os-title-stack {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.accordion-os-icon-btn {
width: 22px;
height: 22px;
border-radius: 9999px;
border: 1.5px solid rgba(2, 2, 2, 0.3);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: rgb(2, 2, 2);
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.04);
}
.accordion-os-svg-icon {
width: 13px;
height: 13px;
}
.accordion-os-title-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
.accordion-os-title-text {
font-size: 15px;
font-weight: 600;
color: #111111;
letter-spacing: -0.015em;
white-space: nowrap;
}
.accordion-os-pill-badge {
font-size: 10px;
font-weight: 700;
color: rgba(0, 0, 0, 0.35);
font-variant-numeric: tabular-nums;
}
.accordion-os-pill-badge-active {
color: rgba(0, 0, 0, 0.75);
}
.accordion-os-claim-container {
overflow: hidden;
width: 100%;
}
.accordion-os-claim-text {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #4b5563;
word-break: break-word;
}
.accordion-os-bottom-deck {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
z-index: 10;
margin-top: auto;
padding-top: 18px;
}
.accordion-os-nav-stack {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 4px;
border-radius: 100px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.accordion-os-nav-btn {
box-sizing: border-box;
margin: 0;
padding: 0;
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 9999px;
background-color: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #1f2937;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.accordion-os-nav-svg {
width: 13px;
height: 13px;
display: block;
}
.accordion-os-counter {
display: flex;
align-items: center;
justify-content: center;
height: 26px;
gap: 4px;
font-size: 11px;
font-weight: 600;
line-height: 1;
color: #374151;
font-variant-numeric: tabular-nums;
}
.accordion-os-counter-divider {
opacity: 0.4;
}
.accordion-os-visual-wrapper {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
}
.accordion-os-image-layer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
will-change: opacity, transform;
}
.accordion-os-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
border-radius: 36px;
display: block;
}
@container (max-width: 640px) {
.accordion-os-card {
flex-direction: column;
justify-content: flex-start;
height: auto !important;
min-height: min(560px, 100%);
padding: 14px;
border-radius: 24px;
}
.accordion-os-sidebar {
width: 100%;
height: auto;
}
.accordion-os-pill {
max-width: 100%;
padding: 11px 14px;
border-radius: 18px;
}
.accordion-os-pill-open {
width: 100%;
max-width: 100%;
}
.accordion-os-title-text {
font-size: 14px;
white-space: normal;
}
.accordion-os-bottom-deck {
margin-top: 18px;
margin-bottom: 12px;
}
.accordion-os-image {
border-radius: 24px;
}
}
@container (max-width: 420px) {
.accordion-os-card {
padding: 10px;
border-radius: 20px;
min-height: 480px;
}
.accordion-os-items-stack {
gap: 8px;
}
.accordion-os-image {
border-radius: 20px;
}
}
` }} />
</div>
);
}
// ==========================================
// 4. MAIN PAGE / APP EXPORT
// ==========================================
export default function Page() {
return (
<TooltipProvider delayDuration={0}>
<div className="flex h-full w-full min-w-0 items-center justify-center overflow-auto p-4">
<AccordionOS
containerWidth="min(52.5rem, 100%)"
containerHeight={520}
autoPlay
autoPlayInterval={3500}
pauseOnHover
/>
</div>
</TooltipProvider>
);
}