Features & services
Services Cards
A heading with a button, then two-column service cards whose photo settles and panel rises on hover.
Layout and motion from the Kalme template — see it in contextimport Link from "next/link";
import { FlipButton } from "@/components/ui/flip-button";
import { ZoomCard } from "@/components/ui/zoom-card";
import { cn } from "@/lib/utils";
/*
* Services Cards — a heading with a button beside it, then services as two-column cards: a photo above a panel with
* the title and a two-line description; the whole card is the link.
* Source: marcframe/kalme → components/sections/MoreServicesSection.tsx (+ design-system ServiceCard).
*
* Layout kept from Framer: below 768px the button sits under the heading; from 768px they share a row, aligned to the
* bottom. Cards: one column below 768px (48px apart), two from 768px (20px apart, rows 48px apart). A card: the photo
* (1.56:1), 10px above the panel; the title on one line and the description on two.
* Motion kept: hovering a card settles the photo, shown at 105%, to 100% (zoom-card, 400ms), while the 10px gap above
* the panel closes and the panel's top padding grows by the same 10px, so the text stays put (Kalme's no-bounce
* spring becomes a 400ms ease-out). The button rolls its label (flip-button).
*/
export type ServiceCardItem = {
title: string;
href: string;
description: string;
/** Decorative photo; the title names the card. */
image: string;
};
export type ServicesCardsProps = {
title?: string;
action?: { label: string; href: string };
services?: ServiceCardItem[];
className?: string;
};
const photo = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=900&q=80`;
const defaults = {
title: "More services",
action: { label: "All services", href: "/services" },
services: [
{
title: "Paid advertising",
href: "/services/paid-advertising",
description: "Search, social and display campaigns tuned every week, with budgets moved to what actually sells.",
image: photo("photo-1551288049-bebda4e38f71"),
},
{
title: "Web design",
href: "/services/web-design",
description: "Fast, clear sites and landing pages designed around the one thing you want a visitor to do.",
image: photo("photo-1432888498266-38ffec3eaf0a"),
},
{
title: "Email marketing",
href: "/services/email-marketing",
description: "Welcome series, newsletters and win-back flows that people open — and that pay for themselves.",
image: photo("photo-1486312338219-ce68d2c6f44d"),
},
{
title: "Workshops",
href: "/services/workshops",
description: "Half-day sessions that get your team aligned on audience, message and the next quarter's plan.",
image: photo("photo-1552664730-d307ca884978"),
},
] as ServiceCardItem[],
};
export function ServicesCards({
title = defaults.title,
action = defaults.action,
services = defaults.services,
className,
}: ServicesCardsProps) {
return (
<section className={cn("py-(--section-y)", className)}>
<div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)">
<div className="flex flex-col items-start gap-7.5 md:flex-row md:items-end md:justify-between">
<h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2>
{action && <FlipButton href={action.href}>{action.label}</FlipButton>}
</div>
<ul className="grid gap-12 md:grid-cols-2 md:gap-x-5">
{services.map((service) => (
<li key={service.href}>
<Link
href={service.href}
className="group flex flex-col rounded-xl outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<ZoomCard zoom={0.952} className="aspect-[1.56/1] rounded-xl">
<img src={service.image} alt="" className="scale-105 object-cover" />
</ZoomCard>
<div className="mt-2.5 flex flex-col gap-2.5 rounded-xl bg-muted px-5 pt-5 pb-5 transition-[margin,padding] duration-400 ease-out group-hover:mt-0 group-hover:pt-7.5 group-focus-visible:mt-0 group-focus-visible:pt-7.5 motion-reduce:transition-none">
<h3 className="line-clamp-1 font-heading text-lg font-semibold tracking-tight">{service.title}</h3>
<p className="line-clamp-2 text-foreground/65">{service.description}</p>
</div>
</Link>
</li>
))}
</ul>
</div>
</section>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/services-cards.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/flip-button.json https://framebrick.co/r/zoom-card.json2. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json3. Copy the block into
components/services-cards.tsx.import Link from "next/link"; import { FlipButton } from "@/components/ui/flip-button"; import { ZoomCard } from "@/components/ui/zoom-card"; import { cn } from "@/lib/utils"; /* * Services Cards — a heading with a button beside it, then services as two-column cards: a photo above a panel with * the title and a two-line description; the whole card is the link. * Source: marcframe/kalme → components/sections/MoreServicesSection.tsx (+ design-system ServiceCard). * * Layout kept from Framer: below 768px the button sits under the heading; from 768px they share a row, aligned to the * bottom. Cards: one column below 768px (48px apart), two from 768px (20px apart, rows 48px apart). A card: the photo * (1.56:1), 10px above the panel; the title on one line and the description on two. * Motion kept: hovering a card settles the photo, shown at 105%, to 100% (zoom-card, 400ms), while the 10px gap above * the panel closes and the panel's top padding grows by the same 10px, so the text stays put (Kalme's no-bounce * spring becomes a 400ms ease-out). The button rolls its label (flip-button). */ export type ServiceCardItem = { title: string; href: string; description: string; /** Decorative photo; the title names the card. */ image: string; }; export type ServicesCardsProps = { title?: string; action?: { label: string; href: string }; services?: ServiceCardItem[]; className?: string; }; const photo = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=900&q=80`; const defaults = { title: "More services", action: { label: "All services", href: "/services" }, services: [ { title: "Paid advertising", href: "/services/paid-advertising", description: "Search, social and display campaigns tuned every week, with budgets moved to what actually sells.", image: photo("photo-1551288049-bebda4e38f71"), }, { title: "Web design", href: "/services/web-design", description: "Fast, clear sites and landing pages designed around the one thing you want a visitor to do.", image: photo("photo-1432888498266-38ffec3eaf0a"), }, { title: "Email marketing", href: "/services/email-marketing", description: "Welcome series, newsletters and win-back flows that people open — and that pay for themselves.", image: photo("photo-1486312338219-ce68d2c6f44d"), }, { title: "Workshops", href: "/services/workshops", description: "Half-day sessions that get your team aligned on audience, message and the next quarter's plan.", image: photo("photo-1552664730-d307ca884978"), }, ] as ServiceCardItem[], }; export function ServicesCards({ title = defaults.title, action = defaults.action, services = defaults.services, className, }: ServicesCardsProps) { return ( <section className={cn("py-(--section-y)", className)}> <div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)"> <div className="flex flex-col items-start gap-7.5 md:flex-row md:items-end md:justify-between"> <h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2> {action && <FlipButton href={action.href}>{action.label}</FlipButton>} </div> <ul className="grid gap-12 md:grid-cols-2 md:gap-x-5"> {services.map((service) => ( <li key={service.href}> <Link href={service.href} className="group flex flex-col rounded-xl outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <ZoomCard zoom={0.952} className="aspect-[1.56/1] rounded-xl"> <img src={service.image} alt="" className="scale-105 object-cover" /> </ZoomCard> <div className="mt-2.5 flex flex-col gap-2.5 rounded-xl bg-muted px-5 pt-5 pb-5 transition-[margin,padding] duration-400 ease-out group-hover:mt-0 group-hover:pt-7.5 group-focus-visible:mt-0 group-focus-visible:pt-7.5 motion-reduce:transition-none"> <h3 className="line-clamp-1 font-heading text-lg font-semibold tracking-tight">{service.title}</h3> <p className="line-clamp-2 text-foreground/65">{service.description}</p> </div> </Link> </li> ))} </ul> </div> </section> ); }
Usage
import { ServicesCards } from "@/components/services-cards";
<ServicesCards
title="More services"
action={{ label: "All services", href: "/services" }}
services={[
{ title: "Paid advertising", href: "/services/paid-advertising", description: "Search, social and display…", image: "/services/ads.jpg" },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | More services | Heading (h2). |
| action | { label, href } | All services | Button beside the heading from 768px, under it on phones. |
| services | { title, href, description, image }[] | 4 services | Cards, each one link; title on one line (h3), description on two. |
| className | string | — | Classes for the <section>. |
- One column below 768px, two from 768px. Photo 1.56:1, 10px above the panel.
- Hovering (or focusing) a card settles the photo from 105% to 100% (zoom-card) while the gap above the panel closes and its top padding grows by the same 10px, so the text stays put.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called ServicesCards (Next.js). - <section className="py-(--section-y)">, container mx-auto max-w-(--container) px-(--gutter), column gap-12. - Header: an h2 (font-heading text-h2 semibold) and a button whose label rolls on hover — stacked on phones (gap-7.5), one row from 768px (justify-between, items-end). - A <ul> grid (gap-12; 2 columns from 768px with gap-x-5). Card: one link (group, rounded-xl, focus ring): a 1.56:1 photo (rounded-xl) at scale 1.05 settling to 1 on hover (400ms), then a panel (rounded-xl bg-muted, mt-2.5, px-5 pt-5 pb-5) whose margin goes to 0 and top padding to 30px on hover/focus-visible (400ms ease-out): an h3 (text-lg semibold, one line) and a two-line description (text-foreground/65). - Only shadcn variables and block tokens; plain <img>.











