The 2 files this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.
Read it first
Install kit: "Ship CTA footer"
Library page: https://astro.baysixmedia.com/components/ship-cta-footer/
Files: 2 required, 1 supporting
Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/components/ShipCtaFooter.astro =====
---
/**
* ShipCtaFooter
*
* A tall dark footer over a photo washed navy-to-black. On top, a big
* uppercase call to action with orange words and an orange pill; under a
* hairline, the brand, a tagline and an email sign-up on the left, and
* Services, Company and Contact columns split by hairlines on the right; at
* the foot, copyright, two legal links and round social buttons.
*
* The sign-up is a real <form> posting to `newsletter.action`; the button
* dims until the email field is valid, but it is never disabled.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Nothing about the business is assumed: no name, heading, pill, link,
* contact line, sign-up form, copyright or photo is shown unless it is
* passed, a block with nothing in it is not drawn, and with no content at
* all nothing renders.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export type ShipFooterSocial = "facebook" | "x" | "instagram" | "linkedin" | "youtube";
export interface ShipFooterLink {
label: string;
href: string;
}
export interface ShipCtaFooterProps {
heading?: string;
/** Heading level for the call to action. */
headingLevel?: "h2" | "p";
cta?: ShipFooterLink | null;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark. Use a light version. */
logo?: { src: string; alt: string; width?: number; height?: number };
tagline?: string;
/** The sign-up form. Off by default; pass your handler as `action`. */
newsletter?: { action: string; label?: string; placeholder?: string; button?: string } | null;
columns?: { heading: string; links: ShipFooterLink[] }[];
contactHeading?: string;
contact?: ShipFooterLink[];
copyright?: string;
legalLinks?: ShipFooterLink[];
social?: { network: ShipFooterSocial; href: string; label?: string }[];
/** Background photo. None by default: the navy gradient alone. */
background?: { src?: string };
navLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ShipCtaFooterProps {}
const {
heading = "",
headingLevel = "h2",
cta = null,
brandName = "",
brandHref = "/",
logo,
tagline = "",
newsletter = null,
columns = [],
contactHeading = "Contact",
contact = [],
copyright,
legalLinks = [],
social = [],
background: bgProp,
navLabel = "Footer",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
const background = { src: "", ...(bgProp ?? {}) };
const year = new Date().getFullYear();
/* No name, no copyright line: never "© 2026 . All rights reserved." */
const copy = copyright ?? (brandName ? `© ${year} ${brandName}. All rights reserved.` : "");
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const hasCta = words.length > 0 || Boolean(cta);
const hasMark = Boolean(logo || brandName);
const hasCols = columns.length > 0 || contact.length > 0;
const hasMain = hasMark || Boolean(tagline) || Boolean(newsletter) || hasCols;
const hasBottom = Boolean(copy) || legalLinks.length > 0 || social.length > 0;
const hasContent = hasCta || hasMain || hasBottom;
const Heading = headingLevel;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const external = (href: string) => /^https?:/i.test(href);
const inputId = `${id ?? "scf"}-email-${Math.random().toString(36).slice(2, 7)}`;
const SOCIAL: Record<ShipFooterSocial, { label: string; path: string }> = {
facebook: { label: "Facebook", path: "M14 8h3V4h-3a4 4 0 0 0-4 4v2H8v4h2v6h4v-6h3l1-4h-4V8Z" },
x: { label: "X", path: "M5 4h4l10 16h-4Z M19 4l-6 7 M5 20l6-7" },
instagram: { label: "Instagram", path: "M8 4h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z M17 7h.01" },
linkedin: { label: "LinkedIn", path: "M5 9v10 M5 5v.01 M10 19v-6a3 3 0 0 1 6 0v6 M10 9v10" },
youtube: { label: "YouTube", path: "M3 12s0-4 1-5 8-1 8-1 7 0 8 1 1 5 1 5 0 4-1 5-8 1-8 1-7 0-8-1-1-5-1-5Z M10 9l5 3-5 3Z" },
};
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<footer class:list={["scf", className]} id={id} data-scf-root>
{background.src && <img class="scf-bg" src={background.src} alt="" width="1600" height="960" loading="lazy" decoding="async" />}
<div class="scf-wash" aria-hidden="true"></div>
<div class="scf-inner">
{hasCta && (
<div class="scf-cta">
{words.length > 0 && (
<Heading class="scf-heading" data-scf-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["scf-w", { "scf-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
{
cta && (
<a class="scf-btn" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="scf-btn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
</div>
)}
{hasMain && (
<div class="scf-main" data-scf-reveal="up">
<div class="scf-brand">
{hasMark && (
<a class="scf-logo" href={safeHref(brandHref)} aria-label={logo ? undefined : `${brandName} home`}>
{
logo ? (
<img src={logo.src} alt={logo.alt} width={logo.width ?? 124} height={logo.height ?? 32} />
) : (
<>
<svg viewBox="0 0 40 40" aria-hidden="true">
<path d="M6 21a14 14 0 0 1 28 0h-6a8 8 0 0 0-16 0Z" fill="currentColor" />
<path d="M20 17 11 30h6v7h6v-7h6Z" fill="currentColor" />
</svg>
<span>{brandName}</span>
</>
)
}
</a>
)}
{tagline && <p class="scf-tagline">{tagline}</p>}
{
newsletter && (
<form class="scf-form" action={safeHref(newsletter.action)} method="post">
<label class="scf-sr" for={inputId}>
{newsletter.label ?? "Email address"}
</label>
<input id={inputId} type="email" name="email" required autocomplete="email" placeholder={newsletter.placeholder ?? "you@company.com"} />
<button type="submit">{newsletter.button ?? "Submit"}</button>
</form>
)
}
</div>
{hasCols && (
<nav class="scf-cols" aria-label={navLabel}>
{
columns.map((col) => (
<div class="scf-col">
<h2 class="scf-col-title">{col.heading}</h2>
<ul>
{col.links.map((l) => (
<li>
<a href={safeHref(l.href)}>{l.label}</a>
</li>
))}
</ul>
</div>
))
}
{
contact.length > 0 && (
<div class="scf-col scf-col--contact">
<h2 class="scf-col-title">{contactHeading}</h2>
<ul>
{contact.map((l) => (
<li>
<a href={safeHref(l.href)} {...(external(l.href) ? { rel: "noopener" } : {})}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2 2l12 6-12 6 3-6Z" fill="currentColor" />
</svg>
{l.label}
</a>
</li>
))}
</ul>
</div>
)
}
</nav>
)}
</div>
)}
{hasBottom && (
<div class="scf-bottom">
{copy && <p class="scf-copy">{copy}</p>}
{
legalLinks.length > 0 && (
<ul class="scf-legal">
{legalLinks.map((l) => (
<li>
<a href={safeHref(l.href)}>{l.label}</a>
</li>
))}
</ul>
)
}
{
social.length > 0 && (
<ul class="scf-social">
{social.map((s) => (
<li>
<a href={safeHref(s.href)} rel="noopener" aria-label={s.label ?? SOCIAL[s.network].label}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d={SOCIAL[s.network].path} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</li>
))}
</ul>
)
}
</div>
)}
</div>
</footer>
)}
<style>
.scf {
--scf-navy: var(--tw-navy, #0b4654);
--scf-accent: var(--tw-accent, #ff6d45);
--scf-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--scf-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--scf-ease: cubic-bezier(0.44, 0, 0.56, 1);
--scf-line: rgb(255 255 255 / 0.1);
position: relative;
isolation: isolate;
overflow: hidden;
background: #07252c;
color: #fff;
font-family: var(--scf-body);
}
.scf *,
.scf *::before,
.scf *::after {
box-sizing: border-box;
}
.scf-bg,
.scf-wash {
position: absolute;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
}
.scf-bg {
object-fit: cover;
}
.scf-wash {
background: linear-gradient(180deg, rgb(11 70 84 / 0.88) 0%, rgb(8 45 54 / 0.9) 55%, rgb(2 10 12 / 0.97) 100%);
}
.scf-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px 32px;
}
.scf-cta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 28px;
padding-bottom: 48px;
border-bottom: 1px solid var(--scf-line);
}
.scf-heading {
max-width: 680px;
margin: 0;
font: 900 30px/1.2 var(--scf-display);
letter-spacing: -0.6px;
text-transform: uppercase;
}
.scf-w {
display: inline-block;
}
.scf-accent {
color: var(--scf-accent);
}
.scf-btn {
display: inline-flex;
flex: none;
align-items: center;
gap: 12px;
height: 50px;
padding: 0 8px 0 25px;
border-radius: 999px;
background: var(--scf-accent);
color: #fff;
font: 400 16px/25.6px var(--scf-body);
letter-spacing: -0.32px;
text-decoration: none;
transition: background-color 0.3s var(--scf-ease);
}
.scf-btn:hover {
background: #052931;
}
.scf-btn-ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: var(--scf-accent);
transition: transform 0.3s var(--scf-ease);
}
.scf-btn-ic svg {
width: 18px;
height: 18px;
}
.scf-btn:hover .scf-btn-ic {
transform: rotate(-45deg);
}
.scf-main {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 48px;
padding: 56px 0;
}
.scf-logo {
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff;
font: 500 26px/1 var(--scf-display);
letter-spacing: -0.5px;
text-decoration: none;
}
.scf-logo svg {
width: 30px;
height: 30px;
color: var(--scf-accent);
}
.scf-tagline {
max-width: 300px;
margin: 36px 0 0;
font: 400 16px/25.6px var(--scf-body);
letter-spacing: -0.32px;
}
.scf-form {
display: flex;
gap: 10px;
max-width: 400px;
margin-top: 40px;
}
.scf-form input {
flex: 1 1 auto;
min-width: 0;
height: 54px;
padding: 0 14px;
border: 0;
border-radius: 10px;
background: #fff;
color: var(--scf-navy);
font: 400 15px/1 var(--scf-body);
}
.scf-form button {
flex: none;
width: 115px;
height: 54px;
border: 0;
border-radius: 10px;
background: var(--scf-accent);
color: #fff;
font: 500 16px/1 var(--scf-body);
cursor: pointer;
transition:
opacity 0.3s var(--scf-ease),
background-color 0.3s var(--scf-ease);
}
.scf-form:has(input:invalid) button {
opacity: 0.5;
}
.scf-form button:hover {
background: #052931;
opacity: 1;
}
.scf-cols {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 36px 24px;
}
.scf-col--contact {
grid-column: 1 / -1;
}
.scf-col-title {
margin: 0 0 14px;
color: rgb(255 255 255 / 0.7);
font: 600 20px/32px var(--scf-body);
letter-spacing: -0.4px;
text-transform: uppercase;
}
.scf-col ul,
.scf-legal,
.scf-social {
margin: 0;
padding: 0;
list-style: none;
}
.scf-col li + li {
margin-top: 15px;
}
.scf-col a,
.scf-legal a {
display: inline-flex;
align-items: center;
gap: 10px;
color: #fff;
font: 500 16px/25.6px var(--scf-body);
letter-spacing: -0.32px;
text-decoration: none;
transition: color 0.3s var(--scf-ease);
}
.scf-col a svg {
flex: none;
width: 16px;
height: 16px;
color: var(--scf-accent);
}
.scf-col a:hover,
.scf-legal a:hover {
color: var(--scf-accent);
}
.scf-bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px 24px;
padding-top: 28px;
border-top: 1px solid var(--scf-line);
}
.scf-copy {
margin: 0;
color: rgb(255 255 255 / 0.7);
font: 400 15px/24px var(--scf-body);
}
.scf-legal {
display: flex;
flex-wrap: wrap;
gap: 12px 40px;
}
.scf-legal a {
color: rgb(255 255 255 / 0.7);
}
.scf-social {
display: flex;
gap: 10px;
}
.scf-social a {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--scf-navy);
color: #fff;
transition: background-color 0.3s var(--scf-ease);
}
.scf-social a:hover {
background: var(--scf-accent);
}
.scf-social svg {
width: 18px;
height: 18px;
}
.scf a:focus-visible,
.scf button:focus-visible,
.scf input:focus-visible {
outline: 2px solid var(--scf-accent);
outline-offset: 3px;
}
.scf-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
[data-scf-reveal="up"] {
transition:
opacity 0.8s var(--scf-ease),
transform 0.8s var(--scf-ease);
}
.scf[data-scf-ready] [data-scf-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.scf-w {
transition:
opacity 0.6s var(--scf-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--scf-ease) calc(var(--i) * 0.06s);
}
.scf[data-scf-ready] [data-scf-reveal="words"]:not([data-in]) .scf-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.scf-inner {
padding: 90px 40px 36px;
}
.scf-cta {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-bottom: 56px;
}
.scf-heading {
font-size: 40px;
letter-spacing: -0.8px;
}
.scf-cols {
grid-template-columns: repeat(3, auto);
justify-content: start;
gap: 0;
}
.scf-col {
padding: 0 40px;
border-left: 1px solid var(--scf-line);
}
.scf-col:first-child {
padding-left: 0;
border-left: 0;
}
.scf-col--contact {
grid-column: auto;
}
}
@media (min-width: 1200px) {
.scf-inner {
padding: 100px 65px 36px;
}
.scf-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.scf-main {
grid-template-columns: 485px minmax(0, 1fr);
gap: 0;
padding: 100px 0 90px;
}
.scf-cols {
justify-content: space-between;
padding-top: 20px;
}
.scf-col {
padding: 0 50px;
}
.scf-col:first-child {
padding-left: 50px;
border-left: 1px solid var(--scf-line);
}
.scf-col--contact {
padding-right: 0;
}
.scf-social a {
width: 35px;
height: 35px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-scf-reveal="up"],
.scf-w,
.scf-btn,
.scf-btn-ic,
.scf-col a,
.scf-legal a,
.scf-social a,
.scf-form button {
transition: none;
}
.scf-btn:hover .scf-btn-ic {
transform: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initShipCtaFooter(root: HTMLElement) {
if (root.dataset.scfReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.scfReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-scf-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.scfReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-scf-root]").forEach(initShipCtaFooter);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ShipCtaFooter.astro =====
===== FILE (supporting): src/components/ShipCtaFooter.md =====
# Ship CTA footer
A tall dark footer over a photo washed navy-to-black. On top, a big uppercase
call to action with orange words and an orange pill; under a hairline, the
brand, a tagline and an email sign-up on the left and Services, Company and
Contact columns split by hairlines on the right; at the foot, copyright, two
legal links and round social buttons.
Catalog page: `/components/ship-cta-footer`
## Files required
- **`ShipCtaFooter.astro`** — the component, with one small appear script.
- `public/ship-cta-footer/ship-at-sea.webp` — the catalog demo's background
photo. Not a default: copy it only if you pass it as `background.src`.
- `ShipCtaFooter.md` — this file.
## Business details
Nothing about the business is built in. With no props the footer renders
nothing; the call-to-action row, brand link, tagline, sign-up form, each
column, the copyright and the bottom bar appear only when they have data.
The copyright defaults to `© <build year> <brandName>. All rights reserved.`
when a `brandName` is given, and is omitted otherwise. The catalog preview's
Tideway content and ship photo are demo data from the registry, not defaults.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `heading` | `string?` | — | Orange words in `*asterisks*`. |
| `headingLevel` | `"h2" \| "p"` | `"h2"` | |
| `cta` | `{ label, href } \| null` | `null` | |
| `brandName`, `brandHref`, `logo` | | —, `/` | `logo` replaces the drawn mark; use a light version. No name and no logo drops the brand link. |
| `tagline` | `string?` | — | |
| `newsletter` | `{ action, label?, placeholder?, button? } \| null` | `null` | The sign-up form, posting `email` to `action`. |
| `columns` | `{ heading, links }[]` | `[]` | |
| `contactHeading`, `contact` | | `"Contact"`, `[]` | Each contact line is a link with an orange arrow. |
| `copyright` | `string?` | `© <build year> <brandName>…` | No `brandName`: no line. `""` hides it. |
| `legalLinks` | `{ label, href }[]` | `[]` | |
| `social` | `{ network, href, label? }[]` | `[]` | facebook, x, instagram, linkedin, youtube. |
| `background` | `{ src? }?` | none | A photo under the wash; without one, the gradient alone. |
| `navLabel`, `fontStylesheet`, `class`, `id` | | | |
## Behaviour (measured)
- **Links:** white (legal: 70% white) → `#ff6d45` on hover.
- **Social buttons:** `#0b4654` → `#ff6d45`.
- **CTA pill:** `#ff6d45` → `#052931`, arrow rotates −45°.
- **Submit:** 50% opacity until the email is valid (the reference shows it
dimmed while empty); never disabled.
- The CTA heading rises word by word; the main block rises from 80px.
## Images and credits
| File | Size | Credit |
| :--- | :--- | :--- |
| `ship-at-sea.webp` (demo only) | 1600 × 960 | Unsplash GFjqsPW7NPY, Unsplash License |
The photo sits behind a 88–97% navy-to-black wash, so any wide ship or
port shot works.
## Approximations
- The reference's footer overlay is a layered image; the wash here is a
single gradient matched by eye.
- Phone column layout (two columns, contact full width) is chosen.
===== END src/components/ShipCtaFooter.md =====
===== FILE (required): public/ship-cta-footer/ship-at-sea.webp =====
[binary asset — download https://astro.baysixmedia.com/ship-cta-footer/ship-at-sea.webp and save it to public/ship-cta-footer/ship-at-sea.webp]
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.
--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
--- 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.