Service Showcase
A clear, considered way to explain what you do. Switch between services, capabilities, and the work behind them.
Good ideas. Built together.
Find the clearest expression of who you are, what you do, and why it matters to the people you want to reach.
- Positioning
- Audience insight
- Brand narrative
Turn a clear story into a useful experience. Distinctive websites and products that feel considered at every step.
- Website design
- User experience
- Design systems
Bring the details to life with fast, accessible websites that your team can keep growing long after launch.
- Creative development
- CMS integration
- Accessibility
Choose a service to see what’s included.
'use client';
import { useId, useRef, useState } from 'react';
import type { KeyboardEvent, ReactNode } from 'react';
import './service-showcase.css';
export interface ServiceShowcaseItem {
id: string;
title: string;
description: string;
capabilities: string[];
/** Decorative media without nested controls; provide a separate href for the call to action. */
media: ReactNode;
href?: string;
linkLabel?: string;
}
export interface ServiceShowcaseProps {
items: ServiceShowcaseItem[];
title?: string;
defaultValue?: string;
onValueChange?: (id: string) => void;
className?: string;
}
/** An editorial services section with one accessible tab panel per offering. */
export function ServiceShowcase({ items, title = 'How we can help', defaultValue, onValueChange, className = '' }: ServiceShowcaseProps) {
const instance = useId();
const [selected, setSelected] = useState(defaultValue ?? items[0]?.id);
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const activeId = items.some(item => item.id === selected) ? selected : items[0]?.id;
function select(index: number, focus = false) {
const item = items[index];
if (!item) return;
if (item.id !== activeId) {
setSelected(item.id);
onValueChange?.(item.id);
}
if (focus) buttons.current[index]?.focus();
}
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
let next: number | undefined;
if (event.key === 'ArrowDown') next = (index + 1) % items.length;
if (event.key === 'ArrowUp') next = (index - 1 + items.length) % items.length;
if (event.key === 'Home') next = 0;
if (event.key === 'End') next = items.length - 1;
if (next === undefined) return;
event.preventDefault();
select(next, true);
}
if (!items.length) return null;
return <section className={`ac-service-showcase ${className}`.trim()} aria-labelledby={`${instance}-heading`}>
<header className="ac-service-showcase-header"><span aria-hidden="true">OUR EXPERTISE</span><h2 id={`${instance}-heading`}>{title}</h2></header>
<div className="ac-service-showcase-layout">
<div className="ac-service-showcase-tabs" role="tablist" aria-label={title} aria-orientation="vertical">
{items.map((item, index) => <button ref={node => { buttons.current[index] = node; }} type="button" role="tab" id={`${instance}-tab-${index}`} aria-selected={item.id === activeId} aria-controls={`${instance}-panel-${index}`} tabIndex={item.id === activeId ? 0 : -1} className="ac-service-showcase-tab" key={item.id} onClick={() => select(index)} onKeyDown={event => onKeyDown(event, index)}><span className="ac-service-showcase-number" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span><span>{item.title}</span><span className="ac-service-showcase-arrow" aria-hidden="true">↗</span></button>)}
</div>
<div className="ac-service-showcase-panels">
{items.map((item, index) => <div role="tabpanel" id={`${instance}-panel-${index}`} aria-labelledby={`${instance}-tab-${index}`} tabIndex={0} hidden={item.id !== activeId} className="ac-service-showcase-panel" key={item.id}>
<div className="ac-service-showcase-media" aria-hidden="true" ref={node => { if (node) node.setAttribute('inert', ''); }}>{item.media}</div>
<div className="ac-service-showcase-copy"><p>{item.description}</p><ul className="ac-service-showcase-capabilities" aria-label={`${item.title} capabilities`}>{item.capabilities.map((capability, capabilityIndex) => <li key={`${capability}-${capabilityIndex}`}>{capability}</li>)}</ul>{item.href && <a className="ac-service-showcase-link" href={item.href}>{item.linkLabel ?? `Explore ${item.title}`}<span aria-hidden="true">↗</span></a>}</div>
</div>)}
</div>
</div>
</section>;
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/service-showcase.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { ServiceShowcase } from '@/components/anticraft/ServiceShowcase';
export function Example() {
return (
<ServiceShowcase
title="Good ideas. Built together."
defaultValue="strategy"
items={[
{
id: 'strategy', title: 'Strategy',
description: 'A clear position, a useful story, and a shared direction for your next chapter.',
capabilities: ['Positioning', 'Audience research', 'Brand narrative'],
media: <img src="/images/strategy.jpg" alt="" />,
href: '/services/strategy', linkLabel: 'Explore our strategy practice',
},
{
id: 'design', title: 'Digital design',
description: 'Distinctive websites and products that feel considered at every step.',
capabilities: ['Website design', 'User experience', 'Design systems'],
media: <img src="/images/digital-design.jpg" alt="" />,
href: '/services/design', linkLabel: 'Explore digital design',
},
{
id: 'development', title: 'Development',
description: 'Fast, accessible websites that your team can keep growing after launch.',
capabilities: ['Creative development', 'CMS integration', 'Accessibility'],
media: <img src="/images/development.jpg" alt="" />,
href: '/services/development', linkLabel: 'Explore development',
},
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | ServiceShowcaseItem[] | Required | Service records with unique id, title, description, a capabilities string array, and decorative ReactNode media. Add optional href and linkLabel for a separate call to action. An empty array renders nothing. |
items[].media | ReactNode | Required | Decorative service imagery, rendered inert and hidden from assistive technology. Keep essential information in the visible description; do not put links or controls inside media. Supply your own image files for the example paths. |
items[].href | string | undefined | Optional destination for a real service or case study page. Omitting it removes the call to action. Replace example URLs with pages in your project. |
items[].linkLabel | string | 'Explore ' + title | The visible and accessible text of the optional call to action. |
title | string | 'How we can help' | Visible section heading and accessible name of the service tab list. |
defaultValue | string | First item | The id of the initial service. If the selected id no longer exists, the first current item is shown. |
onValueChange | (id: string) => void | undefined | Called when a visitor selects a different service with a click, tap, or tab-list keyboard shortcut. |
className | string | '' | Additional classes. Theme with --ac-service-surface, --ac-service-ink, --ac-service-muted, --ac-service-accent, --ac-service-line, and --ac-service-font on this element. |
Motion & accessibility
Click or tap a service to replace its description, capabilities, artwork, and optional call to action. The vertical tab list uses a single Tab stop; Up/Down, Home, and End select and focus services. Tab then reaches the active panel and its link. Inactive panels remain hidden and cannot receive focus. Content enters once on selection; reduced motion removes the transition. A container query stacks the tabs and content below 500px, so it works inside a narrow column as well as a full-width section.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.
Inspiration
Whipsaw’s expertise pages group an offering with a description, capabilities, and related work. That useful content structure informed this original, compact services section. The tab interaction and artwork are an independent Anticraft design.
Visit Whipsaw — ExpertiseThe component source and demo artwork are original to Anticraft UI. The linked site is the design or content reference.