Features & services
Services Alternate
A row per service: a panel with the description, tools and a link beside a large photo, sides alternating; rows fill on hover.
Layout and motion from the Kalme template — see it in contextimport Link from "next/link";
import { ArrowFlipLink } from "@/components/ui/arrow-flip-link";
import { ZoomCard } from "@/components/ui/zoom-card";
import { cn } from "@/lib/utils";
/*
* Services Alternate — a split title, then a row per service: a panel with the description, the tools and a link,
* beside a large photo, the photo's side alternating from row to row.
* Source: marcframe/kalme → components/sections/ServicesListSection.tsx (+ design-system ServiceRow, CheckList,
* ArrowLink).
*
* Layout kept from Framer: rows 48px apart. Below 768px the photo (280px tall) sits above the panel; from 768px the
* panel (400px) comes first and the photo fills the rest; from 1024px the panel is 342px and every second row puts the
* photo first. Panel and photo are 10px apart. Panel: the title and description, the tools as a check list, and the
* link at the bottom.
* Motion kept: from 1024px, hovering a row fills it — the gap between panel and photo included — with the panel's
* surface, and the photo, shown at 105%, settles to 100% (zoom-card, 400ms). The link's arrow leaves to the top right
* as another comes in (arrow-flip-link, spring bounce 0, 0.4s), and its circle takes the theme's primary on hover.
* Changed: Kalme turns the link red on hover; here the colour stays the theme's (a template's accent doesn't come
* with the block). The photo also links to the service; it repeats the "More details" link, so it is hidden from
* assistive tech and the keyboard. Dropped: Kalme's red dot in the label.
*/
export type AlternateService = {
title: string;
href: string;
description: string;
tools?: string[];
/** Decorative photo; the panel names the service. */
image: string;
};
export type ServicesAlternateProps = {
/** Small line above the heading. */
label?: string;
title?: string;
lead?: string;
services?: AlternateService[];
labels?: { tools: string; more: string };
className?: string;
};
const photo = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=900&q=80`;
const defaults = {
label: "Services",
title: "Everything a brand needs to grow",
lead: "Strategy, content and media under one roof — pick one service or let us run the whole plan.",
services: [
{
title: "Social media",
href: "/services/social-media",
description: "Channels that sound like you and grow every month: planning, posting, community and paid reach.",
tools: ["Meta Business Suite", "TikTok Ads", "Later"],
image: photo("photo-1515378791036-0648a3ef77b2"),
},
{
title: "Search & analytics",
href: "/services/search-analytics",
description: "Technical SEO, content that ranks and dashboards that show what each channel really brings in.",
tools: ["Google Analytics", "Search Console", "Looker Studio"],
image: photo("photo-1460925895917-afdab827c52f"),
},
{
title: "Content & copy",
href: "/services/content",
description: "Articles, scripts and campaigns written for people first — and built to be found.",
tools: ["Notion", "Grammarly", "Figma"],
image: photo("photo-1455390582262-044cdead277a"),
},
{
title: "Brand strategy",
href: "/services/brand-strategy",
description: "Positioning, voice and identity work that gives every later campaign a clear place to start.",
tools: ["Miro", "Figma", "Typeform"],
image: photo("photo-1531403009284-440f080d1e12"),
},
] as AlternateService[],
labels: { tools: "Tools", more: "More details" },
};
export function ServicesAlternate({
label = defaults.label,
title = defaults.title,
lead = defaults.lead,
services = defaults.services,
labels = defaults.labels,
className,
}: ServicesAlternateProps) {
return (
<section className={cn("py-(--section-y)", className)}>
<div className="mx-auto flex max-w-(--container) flex-col gap-16 px-(--gutter) md:gap-20 lg:gap-24">
<div className="flex flex-col gap-5 md:flex-row md:items-end md:gap-12 lg:gap-24">
<div className="flex flex-col gap-2.5 md:flex-1">
{label && <p className="text-sm font-medium text-muted-foreground">{label}</p>}
<h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2>
</div>
{lead && <p className="max-w-100 text-pretty text-muted-foreground md:flex-1">{lead}</p>}
</div>
<ul className="flex flex-col gap-12">
{services.map((service, i) => (
<li
key={service.href}
className={cn(
"group flex flex-col-reverse gap-2.5 rounded-xl transition-colors duration-400 ease-out md:flex-row lg:hover:bg-muted motion-reduce:transition-none",
i % 2 === 1 && "lg:flex-row-reverse",
)}
>
<div className="flex flex-col items-start gap-12 rounded-xl bg-muted p-5 md:w-100 md:shrink-0 md:px-5 md:py-7.5 lg:w-85.5 lg:p-7.5">
<div className="flex w-full flex-col gap-7.5">
<div className="flex flex-col gap-4">
<h3 className="font-heading text-lg font-semibold tracking-tight">{service.title}</h3>
<p className="text-pretty text-foreground/65">{service.description}</p>
</div>
{service.tools && service.tools.length > 0 && (
<div className="flex flex-col gap-4">
<p className="font-medium">{labels.tools}</p>
<ul className="flex flex-col gap-2.5">
{service.tools.map((tool) => (
<li key={tool} className="flex items-center gap-2.5 text-foreground/65">
<svg aria-hidden viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className="size-5 shrink-0">
<path d="M4.5 10.5l3.5 3.5 7.5-8" />
</svg>
{tool}
</li>
))}
</ul>
</div>
)}
</div>
<ArrowFlipLink href={service.href} circleClassName="bg-foreground text-background group-hover:bg-primary group-hover:text-primary-foreground">
{labels.more}
<span className="sr-only">: {service.title}</span>
</ArrowFlipLink>
</div>
<Link href={service.href} tabIndex={-1} aria-hidden className="block h-70 md:h-auto md:min-h-80 md:flex-1">
<ZoomCard zoom={0.952} className="size-full rounded-xl">
<img src={service.image} alt="" className="scale-105 object-cover" />
</ZoomCard>
</Link>
</li>
))}
</ul>
</div>
</section>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/services-alternate.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/arrow-flip-link.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-alternate.tsx.import Link from "next/link"; import { ArrowFlipLink } from "@/components/ui/arrow-flip-link"; import { ZoomCard } from "@/components/ui/zoom-card"; import { cn } from "@/lib/utils"; /* * Services Alternate — a split title, then a row per service: a panel with the description, the tools and a link, * beside a large photo, the photo's side alternating from row to row. * Source: marcframe/kalme → components/sections/ServicesListSection.tsx (+ design-system ServiceRow, CheckList, * ArrowLink). * * Layout kept from Framer: rows 48px apart. Below 768px the photo (280px tall) sits above the panel; from 768px the * panel (400px) comes first and the photo fills the rest; from 1024px the panel is 342px and every second row puts the * photo first. Panel and photo are 10px apart. Panel: the title and description, the tools as a check list, and the * link at the bottom. * Motion kept: from 1024px, hovering a row fills it — the gap between panel and photo included — with the panel's * surface, and the photo, shown at 105%, settles to 100% (zoom-card, 400ms). The link's arrow leaves to the top right * as another comes in (arrow-flip-link, spring bounce 0, 0.4s), and its circle takes the theme's primary on hover. * Changed: Kalme turns the link red on hover; here the colour stays the theme's (a template's accent doesn't come * with the block). The photo also links to the service; it repeats the "More details" link, so it is hidden from * assistive tech and the keyboard. Dropped: Kalme's red dot in the label. */ export type AlternateService = { title: string; href: string; description: string; tools?: string[]; /** Decorative photo; the panel names the service. */ image: string; }; export type ServicesAlternateProps = { /** Small line above the heading. */ label?: string; title?: string; lead?: string; services?: AlternateService[]; labels?: { tools: string; more: string }; className?: string; }; const photo = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=900&q=80`; const defaults = { label: "Services", title: "Everything a brand needs to grow", lead: "Strategy, content and media under one roof — pick one service or let us run the whole plan.", services: [ { title: "Social media", href: "/services/social-media", description: "Channels that sound like you and grow every month: planning, posting, community and paid reach.", tools: ["Meta Business Suite", "TikTok Ads", "Later"], image: photo("photo-1515378791036-0648a3ef77b2"), }, { title: "Search & analytics", href: "/services/search-analytics", description: "Technical SEO, content that ranks and dashboards that show what each channel really brings in.", tools: ["Google Analytics", "Search Console", "Looker Studio"], image: photo("photo-1460925895917-afdab827c52f"), }, { title: "Content & copy", href: "/services/content", description: "Articles, scripts and campaigns written for people first — and built to be found.", tools: ["Notion", "Grammarly", "Figma"], image: photo("photo-1455390582262-044cdead277a"), }, { title: "Brand strategy", href: "/services/brand-strategy", description: "Positioning, voice and identity work that gives every later campaign a clear place to start.", tools: ["Miro", "Figma", "Typeform"], image: photo("photo-1531403009284-440f080d1e12"), }, ] as AlternateService[], labels: { tools: "Tools", more: "More details" }, }; export function ServicesAlternate({ label = defaults.label, title = defaults.title, lead = defaults.lead, services = defaults.services, labels = defaults.labels, className, }: ServicesAlternateProps) { return ( <section className={cn("py-(--section-y)", className)}> <div className="mx-auto flex max-w-(--container) flex-col gap-16 px-(--gutter) md:gap-20 lg:gap-24"> <div className="flex flex-col gap-5 md:flex-row md:items-end md:gap-12 lg:gap-24"> <div className="flex flex-col gap-2.5 md:flex-1"> {label && <p className="text-sm font-medium text-muted-foreground">{label}</p>} <h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2> </div> {lead && <p className="max-w-100 text-pretty text-muted-foreground md:flex-1">{lead}</p>} </div> <ul className="flex flex-col gap-12"> {services.map((service, i) => ( <li key={service.href} className={cn( "group flex flex-col-reverse gap-2.5 rounded-xl transition-colors duration-400 ease-out md:flex-row lg:hover:bg-muted motion-reduce:transition-none", i % 2 === 1 && "lg:flex-row-reverse", )} > <div className="flex flex-col items-start gap-12 rounded-xl bg-muted p-5 md:w-100 md:shrink-0 md:px-5 md:py-7.5 lg:w-85.5 lg:p-7.5"> <div className="flex w-full flex-col gap-7.5"> <div className="flex flex-col gap-4"> <h3 className="font-heading text-lg font-semibold tracking-tight">{service.title}</h3> <p className="text-pretty text-foreground/65">{service.description}</p> </div> {service.tools && service.tools.length > 0 && ( <div className="flex flex-col gap-4"> <p className="font-medium">{labels.tools}</p> <ul className="flex flex-col gap-2.5"> {service.tools.map((tool) => ( <li key={tool} className="flex items-center gap-2.5 text-foreground/65"> <svg aria-hidden viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className="size-5 shrink-0"> <path d="M4.5 10.5l3.5 3.5 7.5-8" /> </svg> {tool} </li> ))} </ul> </div> )} </div> <ArrowFlipLink href={service.href} circleClassName="bg-foreground text-background group-hover:bg-primary group-hover:text-primary-foreground"> {labels.more} <span className="sr-only">: {service.title}</span> </ArrowFlipLink> </div> <Link href={service.href} tabIndex={-1} aria-hidden className="block h-70 md:h-auto md:min-h-80 md:flex-1"> <ZoomCard zoom={0.952} className="size-full rounded-xl"> <img src={service.image} alt="" className="scale-105 object-cover" /> </ZoomCard> </Link> </li> ))} </ul> </div> </section> ); }
Usage
import { ServicesAlternate } from "@/components/services-alternate";
<ServicesAlternate
title="Everything a brand needs to grow"
services={[
{
title: "Social media",
href: "/services/social-media",
description: "Channels that sound like you…",
tools: ["Meta Business Suite", "Later"],
image: "/services/social.jpg",
},
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label / title / lead | string | — | Muted line, heading (h2) and the lead beside it from 768px. |
| services | { title, href, description, tools?, image }[] | 4 services | One row each; titles are h3; the photo is decorative and links to the service too. |
| labels | { tools, more } | Tools, More details | Text inside the panel. |
| className | string | — | Classes for the <section>. |
- Below 768px the photo (280px) sits above the panel; from 768px the panel (400px) comes first; from 1024px the panel is 342px and every second row puts the photo first.
- From 1024px hovering a row fills it with the panel's surface and settles the photo from 105% to 100% (zoom-card). The link's arrow flips (arrow-flip-link) and its circle turns to the theme's primary; Kalme's red stays with the template.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called ServicesAlternate (Next.js). - <section className="py-(--section-y)">, container mx-auto max-w-(--container) px-(--gutter), column gap-16 / 20 / 24. - Split title: a small muted label and an h2 (font-heading text-h2 semibold); beside it from 768px (items-end) a muted lead up to 400px. - A list of rows (gap-12). Row (group, rounded-xl, gap-2.5): photo above the panel on phones (flex-col-reverse); from 768px panel (400px) then photo (flex-1); from 1024px panel 342px and every second row reversed; lg:hover:bg-muted (400ms). - Panel (rounded-xl bg-muted, p-5 / md:px-5 md:py-7.5 / lg:p-7.5, gap-12): an h3 (text-lg semibold) and description (text-foreground/65), a "Tools" list with check icons, then a "More details" link with a 18px round arrow that flips out to the top right on hover. - Photo: an aria-hidden link (tabIndex -1), rounded-xl, 280px tall on phones; the image at scale 1.05 settles to 1 while the row is hovered. - Only shadcn variables and block tokens (secondary text on bg-muted is text-foreground/65: muted-foreground falls under 4.5:1 there); plain <img>.











