Component library
Back to catalog

Business details footer

A dark site footer for a business people call or visit: name and a line about it opposite the phone number set large and an optional action, then columns for services, pages, contact details and towns served — each only if given — over a copyright, legal and social bar.

footernavigationcontactclick-to-callservice arealocal servicedark

Open preview in a new tab

Use this component

Three ways to get Business details footer into one of your own projects. All pre-filled with this component's name and library page.

Or, for the design alone

Placement prompt

What to do, where it goes and what must not change. Paste it into Claude Code inside the destination project.

Read it first
Add Business details footer from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/business-details-footer/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
No verification run has been recorded.
Passed: integration route, keyboard, no javascript, reduced motion, two instances, no overflow.
This record pre-dates source fingerprinting, so it is not tied to a specific revision.

--- NOT PROVEN ---
- ClientRouter: not applicable — this component ships no script.

--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.

--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Business details footer” from my Astro Component Library:
https://astro.baysixmedia.com/components/business-details-footer/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

src/components/library/BusinessDetailsFooter.astro

Installing this one is more than pasting a section

  • installOne required file, plus supporting files worth taking:
    • src/components/BusinessDetailsFooter.astrocomponentrequired
    • src/components/BusinessDetailsFooter.mdreadmesupporting
    Use Copy header kit above to get them all, each one labelled with the path it belongs at.
---
/**
 * BusinessDetailsFooter
 *
 * A site footer for a business people call, visit or book: who it is, how to
 * reach it, where it works, and where everything on the site lives. A top row
 * carries the name (or logo) and a line about the business opposite the phone
 * number set large and an optional action. Under a rule, up to four columns —
 * services, other pages, contact details, towns served — each only if given.
 * A bottom bar holds the copyright, legal links and social links.
 *
 * Nothing about the business is assumed. No name, phone, email, address,
 * hours, town, social profile, licence or link is shown unless it is passed,
 * and a block with nothing in it is not drawn — with nothing at all, nothing
 * renders.
 *
 * No script. Reads --bdf-* tokens from any ancestor, with fallbacks.
 */

export interface BusinessDetailsFooterLink {
	label: string;
	href: string;
}

export interface BusinessDetailsFooterProps {
	/** The business name. Shown as the wordmark when there is no logo, and in the copyright. Omitted: no wordmark, no copyright. */
	businessName?: string;
	/** One or two sentences about the business. */
	description?: string;
	/** A logo in place of the text wordmark. `alt` defaults to the business name. */
	logo?: { src: string; alt?: string; width?: number; height?: number } | null;
	homeHref?: string;
	/** A real number only. `href` defaults to `tel:` plus its digits. */
	phone?: { number: string; label?: string; href?: string } | null;
	email?: string;
	/** The address as it should read. A string, or one line per entry. */
	address?: string | readonly string[];
	/** A link for the address, e.g. a maps search URL. */
	addressHref?: string;
	/** Real opening hours. A string, or one line per entry. */
	hours?: string | readonly string[];
	/** Towns or neighbourhoods served, listed in their own column. */
	serviceAreas?: readonly string[];
	/** A single line about the area served, e.g. "Serving the Peninsula since 2004". Shown above the list, or alone. */
	serviceAreaLine?: string;
	serviceAreasLabel?: string;
	serviceLinks?: readonly BusinessDetailsFooterLink[];
	serviceLinksLabel?: string;
	/** Other pages: About, Reviews, Contact. */
	primaryLinks?: readonly BusinessDetailsFooterLink[];
	primaryLinksLabel?: string;
	legalLinks?: readonly BusinessDetailsFooterLink[];
	/** Social profiles, by name: { label: "Facebook", href: "…" }. */
	social?: readonly BusinessDetailsFooterLink[];
	/** One action beside the phone number. */
	cta?: { label: string; href: string } | null;
	contactLabel?: string;
	/** Labels for the contact rows. */
	labels?: { phone?: string; email?: string; address?: string; hours?: string };
	/** Replaces "© {year} {businessName}". An empty string hides it. */
	copyright?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends BusinessDetailsFooterProps {}

const {
	businessName = "",
	description = "",
	logo = null,
	homeHref = "/",
	phone = null,
	email = "",
	address = "",
	addressHref = "",
	hours = "",
	serviceAreas = [],
	serviceAreaLine = "",
	serviceAreasLabel = "Areas we serve",
	serviceLinks = [],
	serviceLinksLabel = "Services",
	primaryLinks = [],
	primaryLinksLabel = "Company",
	legalLinks = [],
	social = [],
	cta = null,
	contactLabel = "Contact",
	labels = {},
	copyright,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&display=swap",
	class: className,
	id,
} = Astro.props;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");

/* A dialable href from a displayed number: keep a leading + and the digits. */
const telHref = (number: string) => {
	const trimmed = number.trim();
	const digits = trimmed.replace(/\D/g, "");
	return digits ? `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}` : "";
};

const lines = (value: string | readonly string[] | undefined) =>
	(typeof value === "string" ? [value] : (value ?? [])).map((l) => (typeof l === "string" ? l.trim() : "")).filter(Boolean);
const links = (list: readonly BusinessDetailsFooterLink[] | undefined) =>
	(list ?? [])
		.filter((l) => l && typeof l.label === "string" && l.label.trim() && typeof l.href === "string" && l.href.trim())
		.map((l) => ({ label: l.label.trim(), href: safeHref(l.href) }));

const phoneNumber = phone?.number?.trim() ?? "";
/* A call link only with somewhere to dial: words in `number` are not a number. */
const passedHref = phone?.href ? safeHref(phone.href) : "#";
const callHref = phoneNumber ? (passedHref !== "#" ? passedHref : telHref(phoneNumber)) : "";
const call = callHref
	? { number: phoneNumber, label: phone?.label?.trim() || `Call ${phoneNumber}`, href: callHref }
	: null;

const name = typeof businessName === "string" ? businessName.trim() : "";
const mark = logo?.src ? { src: logo.src, alt: logo.alt ?? name, width: logo.width, height: logo.height } : null;
const mail = email.trim();
const place = lines(address);
const open = lines(hours);
const areas = (serviceAreas ?? []).map((a) => (typeof a === "string" ? a.trim() : "")).filter(Boolean);
const areaLine = serviceAreaLine.trim();
const services = links(serviceLinks);
const pages = links(primaryLinks);
const legal = links(legalLinks);
const profiles = links(social);
const action = cta && cta.label?.trim() && cta.href?.trim() ? { label: cta.label.trim(), href: safeHref(cta.href) } : null;

const L = { phone: "Phone", email: "Email", address: "Address", hours: "Hours", ...labels };
const hasContact = Boolean(call || mail || place.length || open.length);
const hasAreas = Boolean(areaLine || areas.length);
const columnCount = [services.length > 0, pages.length > 0, hasContact, hasAreas].filter(Boolean).length;
const year = new Date().getFullYear();
const copy = copyright ?? (name ? `© ${year} ${name}` : "");
const hasBrand = Boolean(name || mark || description);
const hasTop = hasBrand || Boolean(call || action);
const hasBottom = Boolean(copy || legal.length || profiles.length);
---

{(hasTop || columnCount > 0 || hasBottom) && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<footer class:list={["bdf", className]} id={id}>
	<div class="bdf-inner">
		{hasTop && (
		<div class="bdf-top">
			{hasBrand && (
			<div class="bdf-brand">
				{(name || mark) && (
				<a class="bdf-mark" href={safeHref(homeHref)}>
					{mark ? <img src={mark.src} alt={mark.alt} width={mark.width} height={mark.height} loading="lazy" decoding="async" /> : name}
				</a>
				)}
				{description && <p class="bdf-desc">{description}</p>}
			</div>
			)}
			{
				(call || action) && (
					<div class="bdf-actions">
						{call && (
							<a class="bdf-call" href={call.href} aria-label={call.label}>
								<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
									<path
										d="M6.6 3.5h2.6l1.4 4-1.9 1.3a11.5 11.5 0 0 0 6.5 6.5l1.3-1.9 4 1.4v2.6c0 1.2-1 2.1-2.2 2A16.6 16.6 0 0 1 4.6 5.7C4.5 4.5 5.4 3.5 6.6 3.5Z"
										fill="currentColor"
									/>
								</svg>
								<span>{call.number}</span>
							</a>
						)}
						{action && (
							<a class="bdf-cta" href={action.href}>
								{action.label}
							</a>
						)}
					</div>
				)
			}
		</div>
		)}

		{
			columnCount > 0 && (
				<div class="bdf-cols" style={`--bdf-count: ${columnCount};`}>
					{services.length > 0 && (
						<nav class="bdf-col" aria-label={serviceLinksLabel}>
							<h2 class="bdf-col-title">{serviceLinksLabel}</h2>
							<ul role="list" class="bdf-links">
								{services.map((l) => (
									<li>
										<a href={l.href}>{l.label}</a>
									</li>
								))}
							</ul>
						</nav>
					)}
					{pages.length > 0 && (
						<nav class="bdf-col" aria-label={primaryLinksLabel}>
							<h2 class="bdf-col-title">{primaryLinksLabel}</h2>
							<ul role="list" class="bdf-links">
								{pages.map((l) => (
									<li>
										<a href={l.href}>{l.label}</a>
									</li>
								))}
							</ul>
						</nav>
					)}
					{hasContact && (
						<div class="bdf-col">
							<h2 class="bdf-col-title">{contactLabel}</h2>
							<address class="bdf-contact">
								<dl>
									{call && (
										<div>
											<dt>{L.phone}</dt>
											<dd>
												<a href={call.href}>{call.number}</a>
											</dd>
										</div>
									)}
									{mail && (
										<div>
											<dt>{L.email}</dt>
											<dd>
												<a href={`mailto:${mail}`}>{mail}</a>
											</dd>
										</div>
									)}
									{place.length > 0 && (
										<div>
											<dt>{L.address}</dt>
											<dd>
												{addressHref.trim() ? (
													<a href={safeHref(addressHref)}>
														{place.map((line, i) => (
															<>
																{i > 0 && <br />}
																{line}
															</>
														))}
													</a>
												) : (
													place.map((line, i) => (
														<>
															{i > 0 && <br />}
															{line}
														</>
													))
												)}
											</dd>
										</div>
									)}
									{open.length > 0 && (
										<div>
											<dt>{L.hours}</dt>
											<dd>
												{open.map((line, i) => (
													<>
														{i > 0 && <br />}
														{line}
													</>
												))}
											</dd>
										</div>
									)}
								</dl>
							</address>
						</div>
					)}
					{hasAreas && (
						<div class="bdf-col">
							<h2 class="bdf-col-title">{serviceAreasLabel}</h2>
							{areaLine && <p class="bdf-area-line">{areaLine}</p>}
							{areas.length > 0 && (
								<ul role="list" class="bdf-areas">
									{areas.map((area) => (
										<li>{area}</li>
									))}
								</ul>
							)}
						</div>
					)}
				</div>
			)
		}

		{hasBottom && (
		<div class="bdf-bottom">
			{copy && <p class="bdf-copy">{copy}</p>}
			{
				legal.length > 0 && (
					<ul role="list" class="bdf-inline" aria-label="Legal">
						{legal.map((l) => (
							<li>
								<a href={l.href}>{l.label}</a>
							</li>
						))}
					</ul>
				)
			}
			{
				profiles.length > 0 && (
					<ul role="list" class="bdf-inline bdf-social" aria-label="Social">
						{profiles.map((l) => (
							<li>
								<a href={l.href} rel="me noopener">
									{l.label}
								</a>
							</li>
						))}
					</ul>
				)
			}
		</div>
		)}
	</div>
</footer>
</>
)}

<style>
	.bdf {
		--bdf-surface: var(--bdf-bg, #0f1b2d);
		--bdf-ink: var(--bdf-text, #f8fafc);
		--bdf-muted: var(--bdf-text-muted, #a7b3c4);
		--bdf-accent: var(--bdf-brand, #1d4ed8);
		--bdf-accent-ink: var(--bdf-brand-text, #ffffff);
		--bdf-focus: var(--bdf-focus-ring, #93b4ff);
		--bdf-rule: var(--bdf-border, rgb(255 255 255 / 0.14));
		--bdf-font: var(--bdf-family, Manrope, ui-sans-serif, system-ui, sans-serif);

		background: var(--bdf-surface);
		color: var(--bdf-ink);
		font-family: var(--bdf-font);
	}

	.bdf *,
	.bdf *::before,
	.bdf *::after {
		box-sizing: border-box;
	}

	.bdf-inner {
		max-width: 1200px;
		margin: 0 auto;
		padding: 48px 16px 24px;
	}

	.bdf-top {
		display: grid;
		gap: 24px;
		padding-bottom: 32px;
		border-bottom: 1px solid var(--bdf-rule);
	}

	.bdf-brand {
		min-width: 0;
	}

	.bdf-mark {
		display: inline-block;
		max-width: 100%;
		color: var(--bdf-ink);
		font: 800 22px/1.25 var(--bdf-font);
		letter-spacing: -0.01em;
		text-decoration: none;
		overflow-wrap: anywhere;
	}

	.bdf-mark img {
		display: block;
		max-width: min(100%, 220px);
		height: auto;
		max-height: 56px;
		object-fit: contain;
	}

	.bdf-desc {
		max-width: 46ch;
		margin: 12px 0 0;
		color: var(--bdf-muted);
		font: 500 15px/1.6 var(--bdf-font);
		overflow-wrap: anywhere;
	}

	.bdf-actions {
		display: flex;
		flex-direction: column;
		gap: 12px;
		min-width: 0;
	}

	.bdf-call {
		display: inline-flex;
		align-items: center;
		gap: 10px;
		min-height: 48px;
		color: var(--bdf-ink);
		font: 800 clamp(22px, 4vw, 28px) / 1.2 var(--bdf-font);
		letter-spacing: -0.01em;
		text-decoration: none;
		overflow-wrap: anywhere;
	}

	.bdf-call svg {
		flex: none;
		width: 24px;
		height: 24px;
		color: var(--bdf-focus);
	}

	.bdf-call:hover span {
		text-decoration: underline;
		text-underline-offset: 5px;
	}

	.bdf-cta {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		min-height: 48px;
		padding: 12px 22px;
		border-radius: 10px;
		background: var(--bdf-accent);
		color: var(--bdf-accent-ink);
		font: 700 16px/1.2 var(--bdf-font);
		text-align: center;
		text-decoration: none;
		overflow-wrap: anywhere;
	}

	.bdf-cta:hover {
		background: var(--bdf-ink);
		color: var(--bdf-surface);
	}

	.bdf-cols {
		display: grid;
		gap: 32px;
		padding: 32px 0;
		border-bottom: 1px solid var(--bdf-rule);
	}

	.bdf-col {
		min-width: 0;
	}

	.bdf-col-title {
		margin: 0 0 14px;
		color: var(--bdf-muted);
		font: 700 12px/1.4 var(--bdf-font);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.bdf-links,
	.bdf-areas,
	.bdf-inline {
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.bdf-links {
		display: grid;
		gap: 2px;
	}

	.bdf-links a {
		display: inline-block;
		padding: 5px 0;
		color: var(--bdf-ink);
		font: 600 15px/1.45 var(--bdf-font);
		text-decoration: none;
		overflow-wrap: anywhere;
	}

	.bdf-links a:hover,
	.bdf-contact a:hover,
	.bdf-inline a:hover {
		text-decoration: underline;
		text-underline-offset: 4px;
	}

	.bdf-contact {
		font-style: normal;
	}

	.bdf-contact dl {
		display: grid;
		gap: 14px;
		margin: 0;
	}

	.bdf-contact dt {
		color: var(--bdf-muted);
		font: 600 13px/1.4 var(--bdf-font);
	}

	.bdf-contact dd {
		margin: 2px 0 0;
		font: 600 15px/1.55 var(--bdf-font);
		overflow-wrap: anywhere;
	}

	.bdf-contact a {
		color: var(--bdf-ink);
		text-decoration: none;
	}

	.bdf-area-line {
		margin: 0 0 10px;
		font: 600 15px/1.55 var(--bdf-font);
		overflow-wrap: anywhere;
	}

	.bdf-areas {
		display: flex;
		flex-wrap: wrap;
		gap: 4px 0;
		color: var(--bdf-muted);
		font: 500 15px/1.6 var(--bdf-font);
	}

	.bdf-areas li {
		max-width: 100%;
		overflow-wrap: anywhere;
	}

	/* Towns read as a run of text, separated by dots, not a cloud of chips. */
	.bdf-areas li:not(:last-child)::after {
		content: "·";
		margin: 0 8px;
		color: var(--bdf-rule);
	}

	.bdf-bottom {
		display: flex;
		flex-direction: column;
		gap: 12px;
		padding-top: 20px;
		color: var(--bdf-muted);
		font: 500 13px/1.5 var(--bdf-font);
	}

	.bdf-copy {
		margin: 0;
		overflow-wrap: anywhere;
	}

	.bdf-inline {
		display: flex;
		flex-wrap: wrap;
		gap: 0 20px;
	}

	.bdf-inline li {
		max-width: 100%;
	}

	.bdf-inline a {
		display: inline-block;
		padding: 6px 0;
		color: var(--bdf-muted);
		text-decoration: none;
		overflow-wrap: anywhere;
	}

	.bdf-mark:focus-visible,
	.bdf-call:focus-visible,
	.bdf-cta:focus-visible,
	.bdf-links a:focus-visible,
	.bdf-contact a:focus-visible,
	.bdf-inline a:focus-visible {
		outline: 3px solid var(--bdf-focus);
		outline-offset: 3px;
		border-radius: 2px;
	}

	@media (min-width: 640px) {
		.bdf-actions {
			flex-direction: row;
			flex-wrap: wrap;
			align-items: center;
			gap: 16px 24px;
		}

		.bdf-cols {
			grid-template-columns: repeat(2, minmax(0, 1fr));
		}
	}

	@media (min-width: 960px) {
		.bdf-inner {
			padding: 64px 32px 28px;
		}

		.bdf-top {
			grid-template-columns: minmax(0, 1fr) auto;
			align-items: center;
			gap: 32px 48px;
		}

		.bdf-actions {
			justify-content: flex-end;
			max-width: 560px;
		}

		.bdf-cols {
			grid-template-columns: repeat(var(--bdf-count), minmax(0, 1fr));
			gap: 40px;
			padding: 40px 0;
		}

		.bdf-bottom {
			flex-direction: row;
			flex-wrap: wrap;
			align-items: center;
			gap: 8px 32px;
		}

		.bdf-social {
			margin-left: auto;
		}
	}

	@media (forced-colors: active) {
		.bdf-cta {
			border: 1px solid ButtonText;
		}
	}
</style>