Footer Columns
A site footer: the logo and a line, up to five columns of links under mono titles, a bottom row with the year, legal pages and Back to top; under a link, a line comes in from the left.
View codeA freelance or studio footer: the email address across the whole width, copied on a click while the line under it rolls to Copied, the local time to the minute, the networks in big rows.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Footer Contact to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<footer class="kodza-footer-contact" data-timezone="Europe/Paris">
<div class="kodza-footer-contact__inner">
<div class="kodza-footer-contact__meta">
<a class="kodza-footer-contact__logo" href="/" aria-label="Pier, home"><svg viewBox="0 0 24 22" width="22" height="20" aria-hidden="true"><path d="M1 6h22v3.5H1z" /><path d="M3.5 11.5h3V21h-3zM10.5 11.5h3V21h-3zM17.5 11.5h3V21h-3z" /></svg><span>pier</span></a>
<p class="kodza-footer-contact__here">Nantes <span class="kodza-footer-contact__time">09:41</span> <span class="kodza-footer-contact__zone">CET</span></p>
<p class="kodza-footer-contact__open"><span class="kodza-footer-contact__dot" aria-hidden="true"></span>Booking from January</p>
</div>
<p class="kodza-footer-contact__lead">No form, no calendar link. An email, answered within a day.</p>
<button class="kodza-footer-contact__email" type="button" data-copy="hello@pier.example">
<span class="kodza-footer-contact__sr">Copy the address </span>
<span class="kodza-footer-contact__address"><span class="kodza-footer-contact__ink">hello@pier.example</span></span>
</button>
<div class="kodza-footer-contact__under">
<p class="kodza-footer-contact__said" role="status" data-copied="Copied. Talk soon." data-failed="This page can't copy. Use the mail app.">Click to copy</p>
<a class="kodza-footer-contact__mail" href="mailto:hello@pier.example">Open mail app<span class="kodza-footer-contact__go" aria-hidden="true"><svg viewBox="0 0 12 12"><path d="M3 9l6-6M4 3h5v5" /></svg><svg viewBox="0 0 12 12"><path d="M3 9l6-6M4 3h5v5" /></svg></span></a>
</div>
<ul class="kodza-footer-contact__socials" aria-label="Elsewhere">
<li>
<a class="kodza-footer-contact__social" href="https://www.instagram.com/">
<span class="kodza-footer-contact__name"><span>Instagram</span></span>
<span class="kodza-footer-contact__handle">@pier.studio</span>
<span class="kodza-footer-contact__go" aria-hidden="true"><svg viewBox="0 0 12 12"><path d="M3 9l6-6M4 3h5v5" /></svg><svg viewBox="0 0 12 12"><path d="M3 9l6-6M4 3h5v5" /></svg></span>
</a>
</li>
<li>
<a class="kodza-footer-contact__social" href="https://www.linkedin.com/">
<span class="kodza-footer-contact__name"><span>LinkedIn</span></span>
<span class="kodza-footer-contact__handle">Pier Studio</span>
<span class="kodza-footer-contact__go" aria-hidden="true"><svg viewBox="0 0 12 12"><path d="M3 9l6-6M4 3h5v5" /></svg><svg viewBox="0 0 12 12"><path d="M3 9l6-6M4 3h5v5" /></svg></span>
</a>
</li>
<li>
<a class="kodza-footer-contact__social" href="https://dribbble.com/">
<span class="kodza-footer-contact__name"><span>Dribbble</span></span>
<span class="kodza-footer-contact__handle">@pierstudio</span>
<span class="kodza-footer-contact__go" aria-hidden="true"><svg viewBox="0 0 12 12"><path d="M3 9l6-6M4 3h5v5" /></svg><svg viewBox="0 0 12 12"><path d="M3 9l6-6M4 3h5v5" /></svg></span>
</a>
</li>
<li>
<a class="kodza-footer-contact__social" href="https://github.com/">
<span class="kodza-footer-contact__name"><span>GitHub</span></span>
<span class="kodza-footer-contact__handle">pier-studio</span>
<span class="kodza-footer-contact__go" aria-hidden="true"><svg viewBox="0 0 12 12"><path d="M3 9l6-6M4 3h5v5" /></svg><svg viewBox="0 0 12 12"><path d="M3 9l6-6M4 3h5v5" /></svg></span>
</a>
</li>
</ul>
<div class="kodza-footer-contact__base">
<p class="kodza-footer-contact__copy">© <span class="kodza-footer-contact__year">2026</span> Pier Studio, Nantes</p>
<ul class="kodza-footer-contact__row">
<li><a class="kodza-footer-contact__link" href="/legal/">Legal notice</a></li>
<li><a class="kodza-footer-contact__link" href="/privacy/">Privacy</a></li>
</ul>
</div>
</div>
</footer>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Footer Contact
The footer of a freelance or a small studio: the email address, set across the whole width of
the footer, ink edge to ink edge. A click copies it and the line under it rolls to "Copied.
Talk soon.", next to Open mail app. Above, the local time to the minute and when you're
booking; below, the networks in big rows whose name rolls up on hover.
Everything is tuned with the --kz-footer-contact-* variables. */
.kodza-footer-contact {
--kz-footer-contact-bg: #ffffff;
--kz-footer-contact-fg: #0a0a0a;
--kz-footer-contact-muted: rgba(10, 10, 10, 0.62);
--kz-footer-contact-line: rgba(10, 10, 10, 0.1);
--kz-footer-contact-width: 1200px; /* the content, centred */
--kz-footer-contact-pad: 24px;
--kz-footer-contact-weight: 500; /* the address */
--kz-footer-contact-tracking: -0.05em;
--kz-footer-contact-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
--kz-footer-contact-ease: cubic-bezier(0.22, 1, 0.36, 1);
display: block;
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 0;
border-top: 1px solid var(--kz-footer-contact-line);
background: var(--kz-footer-contact-bg);
color: var(--kz-footer-contact-fg);
font-size: 15px;
line-height: 1.5;
text-align: left;
container: kodza-footer-contact / inline-size;
}
/* Centred by its margins (no margin: auto, which some browsers read late) */
.kodza-footer-contact__inner {
box-sizing: border-box;
margin: 0 max(0px, calc((100% - var(--kz-footer-contact-width)) / 2));
padding-top: clamp(40px, 5vw, 64px);
padding-bottom: 28px;
padding-left: max(var(--kz-footer-contact-pad), env(safe-area-inset-left, 0px));
padding-right: max(var(--kz-footer-contact-pad), env(safe-area-inset-right, 0px));
}
.kodza-footer-contact__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 32px;
}
.kodza-footer-contact__logo {
display: inline-flex;
align-items: center;
gap: 8px;
height: 40px;
margin: 0 auto 0 0;
border-radius: 6px;
color: inherit;
font-size: 18px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.04em;
text-decoration: none;
}
.kodza-footer-contact__logo svg {
display: block;
flex: none;
fill: currentColor;
}
.kodza-footer-contact__here,
.kodza-footer-contact__open,
.kodza-footer-contact__copy {
margin: 0;
font-family: var(--kz-footer-contact-mono);
font-size: 12px;
font-weight: 400;
letter-spacing: 0.02em;
line-height: 20px;
color: var(--kz-footer-contact-muted);
white-space: nowrap;
}
/* The minutes roll in their own window */
.kodza-footer-contact__time {
display: inline-grid;
overflow: hidden;
vertical-align: top;
color: var(--kz-footer-contact-fg);
font-variant-numeric: tabular-nums;
}
.kodza-footer-contact__time > span,
.kodza-footer-contact__said > span {
grid-area: 1 / 1;
justify-self: start;
}
.kodza-footer-contact__open {
display: inline-flex;
align-items: center;
gap: 10px;
}
/* Taking work: a dot, and a ring that keeps leaving it */
.kodza-footer-contact__dot {
position: relative;
display: block;
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--kz-footer-contact-fg);
}
.kodza-footer-contact__dot::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--kz-footer-contact-fg);
opacity: 0;
animation: kodza-footer-contact-ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes kodza-footer-contact-ping {
0% {
opacity: 0.6;
transform: scale(1);
}
80%,
100% {
opacity: 0;
transform: scale(2.6);
}
}
.kodza-footer-contact__lead {
max-width: 30em;
margin: clamp(48px, 6.5vw, 88px) 0 0;
font-size: clamp(16px, 1.4vw, 18px);
line-height: 1.5;
color: var(--kz-footer-contact-muted);
text-wrap: pretty;
}
/* The address is a button: it copies. The box is the ink of the letters, from the top of the
tallest to the bottom of the lowest, and the script sets the size that fills the width */
.kodza-footer-contact__email {
appearance: none;
position: relative;
display: block;
width: 100%;
margin: clamp(16px, 2vw, 28px) 0 0;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.kodza-footer-contact__address {
display: flow-root;
height: var(--kz-footer-contact-height, auto);
font-size: var(--kz-footer-contact-size, 9cqi);
font-weight: var(--kz-footer-contact-weight);
line-height: 1.2;
letter-spacing: var(--kz-footer-contact-tracking);
white-space: nowrap;
}
.kodza-footer-contact__ink {
display: block;
margin-top: calc(var(--kz-footer-contact-lift, 0px) * -1);
margin-left: calc(var(--kz-footer-contact-shift, 0px) * -1);
}
/* Empty, it sits on the baseline: the script reads the line from it */
.kodza-footer-contact__base-line {
display: inline-block;
}
.kodza-footer-contact__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* Under the address, a line that inks from the left while the pointer is on it, and stays inked
while it says Copied */
.kodza-footer-contact__under {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 24px;
margin-top: clamp(16px, 2vw, 28px);
padding-top: 14px;
border-top: 1px solid var(--kz-footer-contact-line);
background: linear-gradient(var(--kz-footer-contact-fg), var(--kz-footer-contact-fg)) no-repeat 100% 0 / 0 1px;
background-origin: border-box;
transition: background-size 0.6s var(--kz-footer-contact-ease);
}
.kodza-footer-contact.is-copied .kodza-footer-contact__under {
background-position: 0 0;
background-size: 100% 1px;
}
.kodza-footer-contact__said {
display: inline-grid;
overflow: hidden;
margin: 0;
font-size: 14px;
line-height: 22px;
color: var(--kz-footer-contact-muted);
white-space: nowrap;
transition: color 0.2s ease;
}
.kodza-footer-contact__said > span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.kodza-footer-contact__said svg {
display: block;
flex: none;
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.kodza-footer-contact.is-copied .kodza-footer-contact__said {
color: var(--kz-footer-contact-fg);
}
.kodza-footer-contact__mail {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 4px;
color: inherit;
font-size: 14px;
font-weight: 500;
line-height: 22px;
text-decoration: none;
}
/* An arrow that leaves by the top right while the next one comes in from the bottom left */
.kodza-footer-contact__go {
position: relative;
display: block;
flex: none;
width: 12px;
height: 12px;
overflow: clip;
}
.kodza-footer-contact__go svg {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 0.45s var(--kz-footer-contact-ease);
}
.kodza-footer-contact__go svg + svg {
transform: translate(-100%, 100%);
}
/* The networks, in big rows. The name has its twin under it (a shadow): on hover both go up */
.kodza-footer-contact__socials {
margin: clamp(56px, 7vw, 96px) 0 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--kz-footer-contact-line);
}
.kodza-footer-contact__socials li {
margin: 0;
padding: 0;
}
.kodza-footer-contact__social {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 16px;
padding: clamp(14px, 1.6vw, 20px) 0;
border-bottom: 1px solid var(--kz-footer-contact-line);
color: inherit;
text-decoration: none;
background: linear-gradient(var(--kz-footer-contact-fg), var(--kz-footer-contact-fg)) no-repeat 100% 100% / 0 1px;
background-origin: border-box;
transition: background-size 0.6s var(--kz-footer-contact-ease);
}
.kodza-footer-contact__name {
display: block;
min-width: 0;
height: 1.2em;
overflow: clip;
font-size: clamp(28px, 3.6vw, 48px);
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.04em;
}
.kodza-footer-contact__name > span {
display: block;
text-shadow: 0 1.2em currentColor;
transition: transform 0.55s var(--kz-footer-contact-ease);
}
.kodza-footer-contact__handle {
font-family: var(--kz-footer-contact-mono);
font-size: 12px;
letter-spacing: 0.02em;
line-height: 20px;
color: var(--kz-footer-contact-muted);
white-space: nowrap;
transition: color 0.2s ease;
}
.kodza-footer-contact__social .kodza-footer-contact__go {
width: 16px;
height: 16px;
}
.kodza-footer-contact__base {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 32px;
padding-top: 20px;
}
.kodza-footer-contact__row {
display: flex;
flex-wrap: wrap;
gap: 4px 20px;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-footer-contact__row li {
margin: 0;
padding: 0;
}
/* Links: the line grows from the left, and shrinks away to the right */
.kodza-footer-contact__link {
color: var(--kz-footer-contact-muted);
font-size: 13px;
line-height: 1.5;
text-decoration: none;
background: linear-gradient(currentColor, currentColor) no-repeat 100% 100% / 0 1px;
transition: background-size 0.4s var(--kz-footer-contact-ease), color 0.2s ease;
}
@media (hover: hover) {
.kodza-footer-contact__email:hover + .kodza-footer-contact__under,
.kodza-footer-contact__social:hover {
background-position: 0 100%;
background-size: 100% 1px;
}
.kodza-footer-contact__email:hover + .kodza-footer-contact__under {
background-position: 0 0;
}
.kodza-footer-contact__email:hover + .kodza-footer-contact__under .kodza-footer-contact__said,
.kodza-footer-contact__social:hover .kodza-footer-contact__handle,
.kodza-footer-contact__link:hover {
color: var(--kz-footer-contact-fg);
}
.kodza-footer-contact__social:hover .kodza-footer-contact__name > span {
transform: translateY(-1.2em);
}
.kodza-footer-contact__mail:hover .kodza-footer-contact__go svg:first-child,
.kodza-footer-contact__social:hover .kodza-footer-contact__go svg:first-child {
transform: translate(100%, -100%);
}
.kodza-footer-contact__mail:hover .kodza-footer-contact__go svg + svg,
.kodza-footer-contact__social:hover .kodza-footer-contact__go svg + svg {
transform: none;
}
.kodza-footer-contact__link:hover {
background-position: 0 100%;
background-size: 100% 1px;
}
}
/* From the keyboard, a row shows what the pointer would */
.kodza-footer-contact__social:focus-visible .kodza-footer-contact__name > span {
transform: translateY(-1.2em);
}
.kodza-footer-contact__social:focus-visible .kodza-footer-contact__handle {
color: var(--kz-footer-contact-fg);
}
.kodza-footer-contact__logo:focus-visible,
.kodza-footer-contact__email:focus-visible,
.kodza-footer-contact__mail:focus-visible,
.kodza-footer-contact__social:focus-visible,
.kodza-footer-contact__link:focus-visible {
outline: 2px solid var(--kz-footer-contact-fg);
outline-offset: 3px;
}
.kodza-footer-contact__email:focus-visible {
outline-offset: 8px;
}
.kodza-footer-contact__social:focus-visible {
border-radius: 4px;
}
.kodza-footer-contact__link:focus-visible {
border-radius: 2px;
}
@media (prefers-reduced-motion: reduce) {
.kodza-footer-contact__under,
.kodza-footer-contact__said,
.kodza-footer-contact__go svg,
.kodza-footer-contact__social,
.kodza-footer-contact__name > span,
.kodza-footer-contact__handle,
.kodza-footer-contact__link {
transition: none;
}
.kodza-footer-contact__dot::after {
animation: none;
}
}
/* Phones: the time and the booking line share a row under the logo, the names come down a size */
@container kodza-footer-contact (max-width: 600px) {
.kodza-footer-contact__meta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 16px;
}
.kodza-footer-contact__logo {
grid-column: 1 / -1;
justify-self: start;
margin: 0 0 8px;
}
.kodza-footer-contact__name {
font-size: 26px;
}
.kodza-footer-contact__social {
gap: 12px;
}
.kodza-footer-contact__base {
flex-direction: column;
align-items: flex-start;
}
}
/* kodza-guard bc3a1ce989cc — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-footer-contact__logo):not(#_),
:where(.kodza-footer-contact__email):not(#_),
:where(.kodza-footer-contact__mail):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-footer-contact__social):not(#_),
:where(.kodza-footer-contact__link):not(#_) {
border-radius: revert;
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-footer-contact:not(#_) {
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
position: revert;
border-right-width: revert;
border-bottom-width: revert;
border-left-width: revert;
border-right-style: revert;
border-bottom-style: revert;
border-left-style: revert;
border-right-color: revert;
border-bottom-color: revert;
border-left-color: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__meta:not(#_),
.kodza-footer-contact__time:not(#_),
.kodza-footer-contact__zone:not(#_),
.kodza-footer-contact__dot:not(#_),
.kodza-footer-contact__sr:not(#_),
.kodza-footer-contact__address:not(#_),
.kodza-footer-contact__ink:not(#_),
.kodza-footer-contact__base-line:not(#_),
.kodza-footer-contact__under:not(#_),
.kodza-footer-contact__go:not(#_),
.kodza-footer-contact__name:not(#_),
.kodza-footer-contact__handle:not(#_),
.kodza-footer-contact__base:not(#_),
.kodza-footer-contact__year:not(#_),
.kodza-footer-contact__logo span:where(:not([class*="kodza-"])):not(#_),
.kodza-footer-contact__time span:where(:not([class*="kodza-"])):not(#_),
.kodza-footer-contact__said span:where(:not([class*="kodza-"])):not(#_),
.kodza-footer-contact__name span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-footer-contact__logo:not(#_) {
border: revert;
padding: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__here:not(#_),
.kodza-footer-contact__open:not(#_),
.kodza-footer-contact__copy:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__lead:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__email:not(#_) {
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-footer-contact__said:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__mail:not(#_) {
border: revert;
margin: revert;
padding: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__socials:not(#_) {
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
box-sizing: revert;
float: revert;
position: revert;
border-right-width: revert;
border-bottom-width: revert;
border-left-width: revert;
border-right-style: revert;
border-bottom-style: revert;
border-left-style: revert;
border-right-color: revert;
border-bottom-color: revert;
border-left-color: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__social:not(#_) {
margin: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
border-top-width: revert;
border-right-width: revert;
border-left-width: revert;
border-top-style: revert;
border-right-style: revert;
border-left-style: revert;
border-top-color: revert;
border-right-color: revert;
border-left-color: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__row:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__link:not(#_) {
border: revert;
margin: revert;
padding: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__logo svg:where(:not([class*="kodza-"])):not(#_),
.kodza-footer-contact__said svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__go svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__socials li:where(:not([class*="kodza-"]):not(.kodza-footer-contact__socials [class*="kodza-"] *)):not(#_),
.kodza-footer-contact__row li:where(:not([class*="kodza-"]):not(.kodza-footer-contact__row [class*="kodza-"] *)):not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-footer-contact__here:not(#_):where(:not([hidden])),
.kodza-footer-contact__lead:not(#_):where(:not([hidden])),
.kodza-footer-contact__socials:not(#_):where(:not([hidden])),
.kodza-footer-contact__copy:not(#_):where(:not([hidden])),
.kodza-footer-contact__link:not(#_):where(:not([hidden])),
.kodza-footer-contact__socials li:where(:not([class*="kodza-"]):not(.kodza-footer-contact__socials [class*="kodza-"] *)):not(#_):where(:not([hidden])),
.kodza-footer-contact__row li:where(:not([class*="kodza-"]):not(.kodza-footer-contact__row [class*="kodza-"] *)):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-footer-contact__here:not(#_):where([hidden]),
.kodza-footer-contact__lead:not(#_):where([hidden]),
.kodza-footer-contact__socials:not(#_):where([hidden]),
.kodza-footer-contact__copy:not(#_):where([hidden]),
.kodza-footer-contact__link:not(#_):where([hidden]),
.kodza-footer-contact__socials li:where(:not([class*="kodza-"]):not(.kodza-footer-contact__socials [class*="kodza-"] *)):not(#_):where([hidden]),
.kodza-footer-contact__row li:where(:not([class*="kodza-"]):not(.kodza-footer-contact__row [class*="kodza-"] *)):not(#_):where([hidden]) {
display: none;
}
.kodza-footer-contact__dot:not(#_)::after {
box-sizing: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-footer-contact:not(#_) {
display: block;
box-sizing: border-box;
padding: 0;
border-top: 1px solid var(--kz-footer-contact-line);
background: var(--kz-footer-contact-bg);
color: var(--kz-footer-contact-fg);
font-size: 15px;
line-height: 1.5;
text-align: left;
}
.kodza-footer-contact__logo:not(#_) {
display: inline-flex;
margin: 0 auto 0 0;
border-radius: 6px;
color: inherit;
font-size: 18px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.04em;
text-decoration: none;
}
.kodza-footer-contact__logo svg:not(#_) {
display: block;
}
.kodza-footer-contact__here:not(#_),
.kodza-footer-contact__open:not(#_),
.kodza-footer-contact__copy:not(#_) {
margin: 0;
font-family: var(--kz-footer-contact-mono);
font-size: 12px;
font-weight: 400;
letter-spacing: 0.02em;
line-height: 20px;
color: var(--kz-footer-contact-muted);
}
.kodza-footer-contact__open:not(#_) {
display: inline-flex;
}
.kodza-footer-contact__lead:not(#_) {
max-width: 30em;
margin: clamp(48px, 6.5vw, 88px) 0 0;
font-size: clamp(16px, 1.4vw, 18px);
line-height: 1.5;
color: var(--kz-footer-contact-muted);
}
.kodza-footer-contact__email:not(#_) {
appearance: none;
position: relative;
display: block;
width: 100%;
margin: clamp(16px, 2vw, 28px) 0 0;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.kodza-footer-contact__said:not(#_) {
display: inline-grid;
margin: 0;
font-size: 14px;
line-height: 22px;
color: var(--kz-footer-contact-muted);
transition: color 0.2s ease;
}
.kodza-footer-contact__said svg:not(#_) {
display: block;
}
.kodza-footer-contact.is-copied .kodza-footer-contact__said:not(#_) {
color: var(--kz-footer-contact-fg);
}
.kodza-footer-contact__mail:not(#_) {
display: inline-flex;
border-radius: 4px;
color: inherit;
font-size: 14px;
font-weight: 500;
line-height: 22px;
text-decoration: none;
}
.kodza-footer-contact__go svg:not(#_) {
position: absolute;
display: block;
transition: transform 0.45s var(--kz-footer-contact-ease);
}
.kodza-footer-contact__socials:not(#_) {
margin: clamp(56px, 7vw, 96px) 0 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--kz-footer-contact-line);
}
.kodza-footer-contact__socials li:not(#_) {
margin: 0;
padding: 0;
}
.kodza-footer-contact__social:not(#_) {
display: grid;
padding: clamp(14px, 1.6vw, 20px) 0;
border-bottom: 1px solid var(--kz-footer-contact-line);
color: inherit;
text-decoration: none;
background: linear-gradient(var(--kz-footer-contact-fg), var(--kz-footer-contact-fg)) no-repeat 100% 100% / 0 1px;
transition: background-size 0.6s var(--kz-footer-contact-ease);
}
.kodza-footer-contact__row:not(#_) {
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-footer-contact__row li:not(#_) {
margin: 0;
padding: 0;
}
.kodza-footer-contact__link:not(#_) {
color: var(--kz-footer-contact-muted);
font-size: 13px;
line-height: 1.5;
text-decoration: none;
background: linear-gradient(currentColor, currentColor) no-repeat 100% 100% / 0 1px;
transition: background-size 0.4s var(--kz-footer-contact-ease), color 0.2s ease;
}
@media (hover: hover) {
.kodza-footer-contact__email:hover + .kodza-footer-contact__under .kodza-footer-contact__said:not(#_),
.kodza-footer-contact__link:hover:not(#_) {
color: var(--kz-footer-contact-fg);
}
}
@media (hover: hover) {
.kodza-footer-contact__link:hover:not(#_) {
background-position: 0 100%;
background-size: 100% 1px;
}
}
.kodza-footer-contact__logo:focus-visible:not(#_),
.kodza-footer-contact__mail:focus-visible:not(#_),
.kodza-footer-contact__social:focus-visible:not(#_),
.kodza-footer-contact__link:focus-visible:not(#_) {
outline: 2px solid var(--kz-footer-contact-fg);
outline-offset: 3px;
}
.kodza-footer-contact__email:focus-visible:not(#_) {
outline: 2px solid var(--kz-footer-contact-fg);
outline-offset: 3px;
}
.kodza-footer-contact__email:focus-visible:not(#_) {
outline-offset: 8px;
}
.kodza-footer-contact__social:focus-visible:not(#_) {
border-radius: 4px;
}
.kodza-footer-contact__link:focus-visible:not(#_) {
border-radius: 2px;
}
@media (prefers-reduced-motion: reduce) {
.kodza-footer-contact__said:not(#_),
.kodza-footer-contact__go svg:not(#_),
.kodza-footer-contact__social:not(#_),
.kodza-footer-contact__link:not(#_) {
transition: none;
}
}
@container kodza-footer-contact (max-width: 600px) {
.kodza-footer-contact__logo:not(#_) {
margin: 0 0 8px;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Footer Contact
// Sets the address across the whole width of the footer, ink edge to ink edge (measured like
// Footer Wordmark, again when the width or the font changes), copies it on a click (the line under
// it rolls to its data-copied words, then back), keeps the local time of data-timezone to the
// minute (the minutes roll) and writes the year of the ©. A copy fires "kodza:copy" with
// { text, ok }.
(() => {
const SELECTOR = '.kodza-footer-contact';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)';
const REF = 100; // the size the address is measured at, in px
const VARS = ['size', 'height', 'lift', 'shift'].map((name) => `--kz-footer-contact-${name}`);
const ICONS = {
copy: '<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v5A1.5 1.5 0 0 0 4 9.5h.5" />',
tick: '<path d="M3 8.5l3.2 3L13 4.5" />',
};
// The ink of one glyph at the reference size, from its origin: where it starts and ends, how
// far it rises above the baseline and drops below it
let context;
const ink = (char, font) => {
context ||= document.createElement('canvas').getContext('2d');
context.font = font;
const m = context.measureText(char);
return { left: -m.actualBoundingBoxLeft, right: m.actualBoundingBoxRight, ascent: m.actualBoundingBoxAscent, descent: Math.max(0, m.actualBoundingBoxDescent) };
};
async function writeText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.append(area);
area.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch {}
area.remove();
return ok;
}
}
// A line rolls into its box: the old one leaves upwards, the new one rises from below, and the
// box eases to its new width instead of jumping
const line = (text, icon) => {
const span = document.createElement('span');
span.textContent = text;
span.dataset.icon = icon || '';
if (icon) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 16 16');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML = ICONS[icon];
span.prepend(svg);
}
return span;
};
const roll = (box, text, icon) => {
const prev = box.lastElementChild;
if (!prev || (prev.textContent === text && prev.dataset.icon === (icon || ''))) return;
const from = box.offsetWidth;
const span = line(text, icon);
box.append(span);
if (reduceMotion) return prev.remove();
const to = span.offsetWidth;
const timing = { duration: 450, easing: EASE };
prev.setAttribute('aria-hidden', 'true');
box.animate([{ width: `${from}px` }, { width: `${to}px` }], { ...timing, fill: 'backwards' });
prev.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' }).finished.then(() => prev.remove(), () => {});
span.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], { ...timing, fill: 'backwards' });
};
// The time at the place, as "14:32" and "CET", in the visitor's zone if data-timezone is wrong
const zoned = (zone, options) => {
try {
return new Intl.DateTimeFormat('en-GB', { ...options, timeZone: zone || undefined });
} catch {
return new Intl.DateTimeFormat('en-GB', options);
}
};
// One instance, with its own state: two copies on a page never interfere. Everything it
// starts is tied to `signal` (listeners get { signal }, observers and timers stop on its
// 'abort' event), so kodza:destroy stops it all.
function mount(root, signal) {
const q = (s) => root.querySelector(s);
root.querySelectorAll('.kodza-footer-contact__year').forEach((el) => {
el.textContent = String(new Date().getFullYear());
});
// ---------- The address, at the width of the footer ----------
const box = q('.kodza-footer-contact__address');
const text = q('.kodza-footer-contact__ink');
if (box && text) {
const address = text.textContent.trim();
const base = document.createElement('span');
base.className = 'kodza-footer-contact__base-line';
text.replaceChildren(address, base);
const glyph = (i) => {
const range = document.createRange();
range.setStart(text.firstChild, i);
range.setEnd(text.firstChild, i + 1);
return range.getBoundingClientRect();
};
// Measured at the reference size, then scaled: the ink of the first letter on the left
// edge, the last one on the right edge, the box from the top of the ink to its bottom.
// Screen pixels are divided by the scale of a parent that would be zoomed by a transform
let width = 0;
const fit = () => {
const w = box.clientWidth;
if (!w || !address) return;
width = w;
VARS.forEach((v) => box.style.removeProperty(v));
box.style.setProperty(VARS[0], `${REF}px`);
const style = getComputedStyle(box);
const font = `${style.fontStyle} ${style.fontWeight} ${REF}px ${style.fontFamily}`;
const chars = [...address];
const marks = chars.map((char) => ink(char, font));
const scale = box.getBoundingClientRect().width / box.offsetWidth || 1;
const row = text.getBoundingClientRect();
const left = (glyph(0).left - row.left) / scale + marks[0].left;
const right = (glyph(address.length - 1).left - row.left) / scale + marks.at(-1).right;
const baseline = (base.getBoundingClientRect().top - row.top) / scale;
const top = baseline - Math.max(...marks.map((m) => m.ascent));
const bottom = baseline + Math.max(...marks.map((m) => m.descent));
const k = w / (right - left);
[REF, bottom - top, top, left].forEach((value, i) => box.style.setProperty(VARS[i], `${value * k}px`));
// The line's ascent is rounded to whole pixels at each size: the baseline is read again at
// the final size, so the top of the ink lands on the top of the box
const final = (base.getBoundingClientRect().top - text.getBoundingClientRect().top) / scale;
box.style.setProperty(VARS[2], `${final - (baseline - top) * k}px`);
};
// A new width refits on the next frame, outside the observer
let frame = 0;
const observer = new ResizeObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (box.clientWidth !== width) fit();
});
});
observer.observe(box);
document.fonts?.addEventListener('loadingdone', fit, { signal });
signal.addEventListener('abort', () => {
observer.disconnect();
cancelAnimationFrame(frame);
});
fit();
document.fonts?.ready.then(() => !signal.aborted && fit());
}
// ---------- The copy ----------
const email = q('.kodza-footer-contact__email');
const said = q('.kodza-footer-contact__said');
if (email && said) {
const idle = said.textContent.trim();
said.replaceChildren(line(idle, 'copy'));
let timer = 0;
email.addEventListener('click', async () => {
const value = email.dataset.copy || text?.textContent.trim() || '';
const ok = await writeText(value);
if (signal.aborted) return;
clearTimeout(timer);
root.classList.toggle('is-copied', ok);
roll(said, ok ? said.dataset.copied || 'Copied.' : said.dataset.failed || 'Copy failed.', ok ? 'tick' : '');
root.dispatchEvent(new CustomEvent('kodza:copy', { bubbles: true, detail: { text: value, ok } }));
timer = setTimeout(() => {
root.classList.remove('is-copied');
roll(said, idle, 'copy');
}, ok ? 2400 : 4000);
}, { signal });
signal.addEventListener('abort', () => clearTimeout(timer));
}
// ---------- The local time, every minute on the minute ----------
const time = q('.kodza-footer-contact__time');
const zone = q('.kodza-footer-contact__zone');
if (time) {
let clock;
let label;
const read = () => {
clock = zoned(root.dataset.timezone, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
label = zoned(root.dataset.timezone, { hour: '2-digit', timeZoneName: 'short' });
};
const update = (moving) => {
const now = new Date();
const hhmm = clock.format(now);
if (moving) roll(time, hhmm);
else time.replaceChildren(line(hhmm));
if (zone) zone.textContent = label.formatToParts(now).find((p) => p.type === 'timeZoneName')?.value || zone.textContent;
};
read();
update(false);
let timer = 0;
const tick = () => {
update(true);
timer = setTimeout(tick, 60000 - (Date.now() % 60000) + 50);
};
timer = setTimeout(tick, 60000 - (Date.now() % 60000) + 50);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) update(true);
}, { signal });
const watch = new MutationObserver(() => {
read();
update(true);
});
watch.observe(root, { attributes: true, attributeFilter: ['data-timezone'] });
signal.addEventListener('abort', () => {
clearTimeout(timer);
watch.disconnect();
});
}
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load. Each running root
// keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
// component inside another one is saved as written, not half-started, and stopped with it.
const KODZA = Symbol.for('kodza');
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
const put = (el, saved) => {
[...el.attributes].forEach((a) => el.removeAttribute(a.name));
saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
el.innerHTML = saved.html;
};
// The markup inside a root as written: Kodza components already running in it give back their own
const written = (root) => {
const all = [...root.querySelectorAll('*')];
if (!all.some((el) => el[KODZA])) return root.innerHTML;
const copy = root.cloneNode(true);
const twins = copy.querySelectorAll('*');
let last = null;
all.forEach((el, i) => {
if (!el[KODZA] || last?.contains(el)) return;
last = el;
put(twins[i], el[KODZA]);
});
return copy.innerHTML;
};
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
const saved = { html: written(root), attrs: attrsOf(root), stop: () => destroy(root) };
instances.set(root, { controller, saved });
root[KODZA] = saved;
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
// The Kodza components running inside it stop first: once its markup is put back, their own
// script could not find them any more
root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
instance.controller.abort();
delete root[KODZA];
put(root, instance.saved);
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Footer Contact, for React. From https://kodza.dev/components/footer-contact/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: footer-contact.css and footer-contact.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './footer-contact.css';
export type FooterContactProps = Omit<ComponentPropsWithoutRef<'footer'>, "children" | "vars" | "timezone" | "onCopy"> & {
/** data-timezone on the root (default: Europe/Paris) */
timezone?: "Europe/Paris";
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-footer-contact-bg"?: string | number;
/** default: #0a0a0a */
"--kz-footer-contact-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-footer-contact-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-footer-contact-line"?: string | number;
/** the content, centred — default: 1200px */
"--kz-footer-contact-width"?: string | number;
/** default: 24px */
"--kz-footer-contact-pad"?: string | number;
/** the address — default: 500 */
"--kz-footer-contact-weight"?: string | number;
/** default: -0.05em */
"--kz-footer-contact-tracking"?: string | number;
/** default: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace */
"--kz-footer-contact-mono"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-footer-contact-ease"?: string | number;
};
/** "kodza:copy", fired by the script (bubbles from the root) */
onCopy?: (event: CustomEvent) => void;
className?: string;
};
export function FooterContact({ timezone = "Europe/Paris", vars, onCopy, className, style, ...rest }: FooterContactProps) {
const ref = useRef<ComponentRef<'footer'>>(null);
const classes = ["kodza-footer-contact", className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, timezone, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./footer-contact.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
useEffect(() => {
const root = ref.current;
if (!root || !onCopy) return;
const listener = (event: Event) => onCopy(event as CustomEvent);
root.addEventListener("kodza:copy", listener);
return () => root.removeEventListener("kodza:copy", listener);
}, [onCopy, key]);
return (
<footer key={key} {...rest} className={classes} data-timezone={timezone} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-footer-contact__inner">
<div className="kodza-footer-contact__meta">
<a className="kodza-footer-contact__logo" href="/" aria-label="Pier, home">
<svg viewBox="0 0 24 22" width="22" height="20" aria-hidden="true">
<path d="M1 6h22v3.5H1z" />
<path d="M3.5 11.5h3V21h-3zM10.5 11.5h3V21h-3zM17.5 11.5h3V21h-3z" />
</svg>
<span>pier</span>
</a>
<p className="kodza-footer-contact__here">Nantes <span className="kodza-footer-contact__time">09:41</span> <span className="kodza-footer-contact__zone">CET</span></p>
<p className="kodza-footer-contact__open"><span className="kodza-footer-contact__dot" aria-hidden="true" />Booking from January</p>
</div>
<p className="kodza-footer-contact__lead">No form, no calendar link. An email, answered within a day.</p>
<button className="kodza-footer-contact__email" type="button" data-copy="hello@pier.example">
<span className="kodza-footer-contact__sr">Copy the address </span>
{' '}
<span className="kodza-footer-contact__address">
<span className="kodza-footer-contact__ink">hello@pier.example</span>
</span>
</button>
<div className="kodza-footer-contact__under">
<p className="kodza-footer-contact__said" role="status" data-copied="Copied. Talk soon." data-failed="This page can't copy. Use the mail app.">Click to copy</p>
<a className="kodza-footer-contact__mail" href="mailto:hello@pier.example">Open mail app<span className="kodza-footer-contact__go" aria-hidden="true"> <svg viewBox="0 0 12 12"> <path d="M3 9l6-6M4 3h5v5" /> </svg> <svg viewBox="0 0 12 12"> <path d="M3 9l6-6M4 3h5v5" /> </svg> </span></a>
</div>
<ul className="kodza-footer-contact__socials" aria-label="Elsewhere">
<li>
<a className="kodza-footer-contact__social" href="https://www.instagram.com/">
<span className="kodza-footer-contact__name">
<span>Instagram</span>
</span>
{' '}
<span className="kodza-footer-contact__handle">@pier.studio</span>
{' '}
<span className="kodza-footer-contact__go" aria-hidden="true">
<svg viewBox="0 0 12 12">
<path d="M3 9l6-6M4 3h5v5" />
</svg>
<svg viewBox="0 0 12 12">
<path d="M3 9l6-6M4 3h5v5" />
</svg>
</span>
</a>
</li>
<li>
<a className="kodza-footer-contact__social" href="https://www.linkedin.com/">
<span className="kodza-footer-contact__name">
<span>LinkedIn</span>
</span>
{' '}
<span className="kodza-footer-contact__handle">Pier Studio</span>
{' '}
<span className="kodza-footer-contact__go" aria-hidden="true">
<svg viewBox="0 0 12 12">
<path d="M3 9l6-6M4 3h5v5" />
</svg>
<svg viewBox="0 0 12 12">
<path d="M3 9l6-6M4 3h5v5" />
</svg>
</span>
</a>
</li>
<li>
<a className="kodza-footer-contact__social" href="https://dribbble.com/">
<span className="kodza-footer-contact__name">
<span>Dribbble</span>
</span>
{' '}
<span className="kodza-footer-contact__handle">@pierstudio</span>
{' '}
<span className="kodza-footer-contact__go" aria-hidden="true">
<svg viewBox="0 0 12 12">
<path d="M3 9l6-6M4 3h5v5" />
</svg>
<svg viewBox="0 0 12 12">
<path d="M3 9l6-6M4 3h5v5" />
</svg>
</span>
</a>
</li>
<li>
<a className="kodza-footer-contact__social" href="https://github.com/">
<span className="kodza-footer-contact__name">
<span>GitHub</span>
</span>
{' '}
<span className="kodza-footer-contact__handle">pier-studio</span>
{' '}
<span className="kodza-footer-contact__go" aria-hidden="true">
<svg viewBox="0 0 12 12">
<path d="M3 9l6-6M4 3h5v5" />
</svg>
<svg viewBox="0 0 12 12">
<path d="M3 9l6-6M4 3h5v5" />
</svg>
</span>
</a>
</li>
</ul>
<div className="kodza-footer-contact__base">
<p className="kodza-footer-contact__copy">© <span className="kodza-footer-contact__year">2026</span> Pier Studio, Nantes</p>
<ul className="kodza-footer-contact__row">
<li>
<a className="kodza-footer-contact__link" href="/legal/">Legal notice</a>
</li>
<li>
<a className="kodza-footer-contact__link" href="/privacy/">Privacy</a>
</li>
</ul>
</div>
</div>
</footer>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.