Apple-style service cards for React with a 3D hover effect and tooltip. Built with Tailwind CSS and Motion.
Sites that feel like a signature, not a template.
Interfaces with a pulse, clear, fast and unforgettable.
Shops that convert the moment they load.
const SERVICES = [
{
alt: "Creative studio workspace",
description: "Sites that feel like a signature, not a template.",
image: "https://picsum.photos/id/160/600/400",
number: "01",
title: "Portfolios",
},
{
alt: "Laptop on a desk",
description: "Interfaces with a pulse, clear, fast and unforgettable.",
image: "https://picsum.photos/id/60/600/400",
number: "02",
title: "Web design",
},
{
alt: "Product photography setup",
description: "Shops that convert the moment they load.",
image: "https://picsum.photos/id/20/600/400",
number: "03",
title: "E-commerce",
},
]
<ServiceCards spreadPx={spreadPx} verticalStaggerPercentage={verticalStaggerPercentage}>
{SERVICES.map(({ alt, description, image, number, title }) => (
<ServiceCardItem key={number}>
<ServiceCardContent className="bg-[#111]">
<Image
alt={alt}
className="scale-105 object-cover object-center opacity-45 transition-all duration-300 group-hover/content:scale-110 group-hover/content:opacity-60"
fill
sizes="300px"
src={image}
/>
<div className="absolute inset-0 bg-linear-to-t from-black/80 via-black/40 to-black/20" />
<div className="relative z-10 flex h-full flex-col justify-end p-5">
<span className="font-medium text-[11px] text-white/45 tabular-nums tracking-[0.22em] transition-colors duration-300 group-hover/content:text-white/80">
{number}
</span>
<h3 className="relative mt-1.5 w-fit font-semibold text-lg text-white/90 tracking-tight">
{title}
<span className="absolute inset-x-0 -bottom-0.5 h-px origin-left scale-x-0 bg-white/80 transition-transform duration-300 group-hover/content:scale-x-100" />
</h3>
<p className="mt-1.5 max-w-55 text-[13px] text-white/55 leading-snug transition-[color,transform] duration-300 group-hover/content:-translate-y-0.5 group-hover/content:text-white/90">
{description}
</p>
</div>
</ServiceCardContent>
<ServiceCardTooltip className="flex items-center gap-2">
<span className="text-[#d1d1d1]">{number}</span>
<span>{title}</span>
</ServiceCardTooltip>
</ServiceCardItem>
))}
</ServiceCards>Copy and paste the following code into your project.
"use client";
import {
Children,
type CSSProperties,
createContext,
type ReactNode,
useContext,
} from "react";
import { cn } from "@/lib/utils";
const SCALE_MIN = 0.82;
interface ServiceCardContextValue {
count: number;
index: number;
spreadPx: number;
verticalStaggerPercentage: number;
}
const ServiceCardContext = createContext<ServiceCardContextValue | null>(null);
const ServiceCardItemContext = createContext(false);
function useServiceCard() {
const ctx = useContext(ServiceCardContext);
if (!ctx) {
throw new Error(
"ServiceCardContent and ServiceCardItem must be used within ServiceCardBox"
);
}
return ctx;
}
function cardProgress(index: number, count: number) {
if (count <= 1) {
return 0;
}
return index / (count - 1);
}
function cardTransforms(
index: number,
count: number,
verticalStaggerPercentage: number,
spreadPx: number
) {
const fromBack = count - 1 - index;
const t = cardProgress(fromBack, count);
const y = verticalStaggerPercentage * (fromBack - (count - 1) / 2);
const scale = SCALE_MIN + (1 - SCALE_MIN) * t;
const z = -spreadPx + 2 * spreadPx * t;
return {
hover: `translate3d(-50%, -50%, ${z}px) scale(1)`,
rest: `translate3d(-50%, calc(-50% + ${y}%), 0) scale(${scale})`,
};
}
const ServiceCards = ({
children,
className,
verticalStaggerPercentage = 16,
spreadPx = 140,
...props
}: {
children: ReactNode;
className?: string;
/**
* The percentage of the vertical distance between each card when closed.
* @default 16
*/
verticalStaggerPercentage?: number;
/**
* The distance between the cards when opened.
* @default 140
*/
spreadPx?: number;
} & React.ComponentProps<"div">) => {
const items = Children.toArray(children);
return (
<div
aria-label="Service Card Box"
className={cn(
"group perspective-[720px] relative h-125 w-125 duration-300 motion-reduce:duration-0",
className
)}
role="list"
{...props}
>
<div className="transform-3d pointer-events-none absolute inset-0 origin-center transition-transform duration-[inherit] group-hover:-rotate-y-45">
{items.map((child, index) => (
<ServiceCardContext.Provider
/* biome-ignore lint/suspicious/noArrayIndexKey: we've just the index */
key={index}
value={{
count: items.length,
index,
spreadPx,
verticalStaggerPercentage,
}}
>
{child}
</ServiceCardContext.Provider>
))}
</div>
</div>
);
};
const ServiceCardShell = ({ children }: { children: ReactNode }) => {
const { index, count, verticalStaggerPercentage, spreadPx } =
useServiceCard();
const { rest, hover } = cardTransforms(
index,
count,
verticalStaggerPercentage,
spreadPx
);
return (
<div
aria-label="Service Card Item"
className="transform-3d transform-(--card-rest) group-hover:transform-(--card-hover) pointer-events-auto absolute top-1/2 left-1/2 h-57.5 w-75 origin-center transition-transform duration-[inherit]"
role="listitem"
style={
{
"--card-hover": hover,
"--card-rest": rest,
zIndex: count - 1 - index,
} as CSSProperties
}
>
<div className="group/content relative size-full duration-[inherit]">
<div className="relative size-full duration-[inherit] group-hover/content:translate-y-[-15%]">
{children}
</div>
</div>
</div>
);
};
const ServiceCardItem = ({ children }: { children: ReactNode }) => (
<ServiceCardItemContext.Provider value={true}>
<ServiceCardShell>{children}</ServiceCardShell>
</ServiceCardItemContext.Provider>
);
const ServiceCardContent = ({
children,
className,
...props
}: {
children: ReactNode;
className?: string;
} & React.ComponentProps<"div">) => {
const insideItem = useContext(ServiceCardItemContext);
const card = (
<div
className={cn(
"relative z-20 size-full overflow-hidden rounded-2xl bg-white shadow-xl transition-transform",
className
)}
{...props}
>
{children}
</div>
);
if (insideItem) {
return card;
}
return <ServiceCardShell>{card}</ServiceCardShell>;
};
const ServiceCardTooltip = ({
children,
className,
...props
}: {
children: ReactNode;
className?: string;
} & React.ComponentProps<"div">) => (
<div
aria-label="Side Card Tooltip"
className={cn(
"absolute top-0 left-1/2 z-10 -translate-x-1/2 rounded-full bg-[#101010] px-2.5 py-1.5 font-medium text-white text-xs opacity-0 duration-[inherit] group-hover/content:-top-10 group-hover/content:opacity-100",
className
)}
role="tooltip"
{...props}
>
{children}
</div>
);
export { ServiceCards, ServiceCardContent, ServiceCardItem, ServiceCardTooltip };<ServiceCards spreadPx={spreadPx} verticalStaggerPercentage={verticalStaggerPercentage}>
<ServiceCardItem>
<ServiceCardContent>
<Image
alt="Card 1"
className="object-cover object-center"
fill
sizes="300px"
src="https://picsum.photos/id/2/300/300"
/>
</ServiceCardContent>
<ServiceCardTooltip className="flex items-center gap-2">
<span className="text-[#d1d1d1]">01</span>
<span>Portfolios</span>
</ServiceCardTooltip>
</ServiceCardItem>
</ServiceCards>Without tooltip, you can pass ServiceCardContent directly to ServiceCards.
Sites that feel like a signature, not a template.
Interfaces with a pulse, clear, fast and unforgettable.
Shops that convert the moment they load.
const SERVICES = [
{
alt: "Creative studio workspace",
description: "Sites that feel like a signature, not a template.",
image: "https://picsum.photos/id/160/600/400",
number: "01",
title: "Portfolios",
},
{
alt: "Laptop on a desk",
description: "Interfaces with a pulse, clear, fast and unforgettable.",
image: "https://picsum.photos/id/60/600/400",
number: "02",
title: "Web design",
},
{
alt: "Product photography setup",
description: "Shops that convert the moment they load.",
image: "https://picsum.photos/id/20/600/400",
number: "03",
title: "E-commerce",
},
];
<ServiceCards>
{SERVICES.map(({ alt, description, image, number, title }) => (
<ServiceCardContent className="bg-[#111]" key={number}>
<Image
alt={alt}
className="scale-105 object-cover object-center opacity-45 transition-all duration-300 group-hover/content:scale-110 group-hover/content:opacity-60"
fill
sizes="300px"
src={image}
/>
<div className="absolute inset-0 bg-linear-to-t from-black/80 via-black/40 to-black/20" />
<div className="relative z-10 flex h-full flex-col justify-end p-5">
<span className="font-medium text-[11px] text-white/45 tabular-nums tracking-[0.22em] transition-colors duration-300 group-hover/content:text-white/80">
{number}
</span>
<h3 className="relative mt-1.5 w-fit font-semibold text-lg text-white/90 tracking-tight">
{title}
<span className="absolute inset-x-0 -bottom-0.5 h-px origin-left scale-x-0 bg-white/80 transition-transform duration-300 group-hover/content:scale-x-100" />
</h3>
<p className="mt-1.5 max-w-55 text-[13px] text-white/55 leading-snug transition-[color,transform] duration-300 group-hover/content:-translate-y-0.5 group-hover/content:text-white/90">
{description}
</p>
</div>
</ServiceCardContent>
))}
</ServiceCards>By default, the component has an area hover of 500px x 500px. You can simply change it via className on the ServiceCard component.
Sites that feel like a signature, not a template.
Interfaces with a pulse, clear, fast and unforgettable.
Shops that convert the moment they load.
<ServiceCards className="h-[400px] w-[800px] border border-black/20 border-dashed">
...
</ServiceCards>ServiceCardsIt accepts all the props of a div element, plus the following:
| Prop | Type | Default | Description |
|---|---|---|---|
spreadPx | number | 140 | The distance between the cards when opened. |
verticalStaggerPercentage | number | 16 | The percentage of the vertical distance between each card when closed. |
ServiceCardContentIt accepts all the props of a div element.
ServiceCardItem| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | undefined | The content of the card. |
className | string | undefined | The class name of the card. |
ServiceCardTooltipIt accepts all the props of a div element.