07Contact and map card footer
A pin drops and bounces on the street-pattern map card while a round ripple spreads, and the address, phone, and mail cards beside it appear one after another.
Best forStore and office footers, directions sections
repeating-linear-gradienttranslateYscale

<div class="stage">
<!-- footer 가 body 계통(article·section·main 안이 아님)에 있으므로 암묵적 role 은 contentinfo 다 -->
<footer class="fc">
<!-- 지도는 장식이지만 누르면 핀이 다시 떨어지므로 button. Enter·Space 는 button 이 스스로 처리한다 -->
<button class="fc__map" type="button" aria-label="Drop the pin again">
<span class="fc__ripple" aria-hidden="true"></span>
<span class="fc__pin" aria-hidden="true">
<svg viewBox="0 0 256 256" width="100%" height="100%" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
</span>
</button>
<div class="fc__side">
<h2 class="fc__title">Directions</h2>
<address class="fc__cards">
<a class="fc__card" href="#">
<span class="fc__ic" aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
</span>
<span class="fc__txt"><span class="fc__k">Address</span><span class="fc__v">12 Main Road, 4F</span></span>
</a>
<a class="fc__card" href="tel:00000000000">
<span class="fc__ic" aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M223.94 174.08A48.33 48.33 0 0 1 176 216A136 136 0 0 1 40 80a48.33 48.33 0 0 1 41.92-47.94a8 8 0 0 1 8.3 4.8l21.13 47.2a8 8 0 0 1-.66 7.53L89.32 117a7.93 7.93 0 0 0-.54 7.81c8.27 16.93 25.77 34.22 42.75 42.41a7.92 7.92 0 0 0 7.83-.59l25-21.3a8 8 0 0 1 7.59-.69l47.16 21.13a8 8 0 0 1 4.83 8.31" opacity=".2"/><path d="m222.37 158.46l-47.11-21.11l-.13-.06a16 16 0 0 0-15.17 1.4a8 8 0 0 0-.75.56L134.87 160c-15.42-7.49-31.34-23.29-38.83-38.51l20.78-24.71c.2-.25.39-.5.57-.77a16 16 0 0 0 1.32-15.06v-.12L97.54 33.64a16 16 0 0 0-16.62-9.52A56.26 56.26 0 0 0 32 80c0 79.4 64.6 144 144 144a56.26 56.26 0 0 0 55.88-48.92a16 16 0 0 0-9.51-16.62M176 208A128.14 128.14 0 0 1 48 80a40.2 40.2 0 0 1 34.87-40a.6.6 0 0 0 0 .12l21 47l-20.67 24.74a6 6 0 0 0-.57.77a16 16 0 0 0-1 15.7c9.06 18.53 27.73 37.06 46.46 46.11a16 16 0 0 0 15.75-1.14a8 8 0 0 0 .74-.56L168.89 152l47 21.05h.11A40.21 40.21 0 0 1 176 208"/></g></svg>
</span>
<span class="fc__txt"><span class="fc__k">Phone</span><span class="fc__v">000-0000-0000</span></span>
</a>
<a class="fc__card" href="mailto:[email protected]">
<span class="fc__ic" aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="m224 56l-96 88l-96-88Z" opacity=".2"/><path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z"/></g></svg>
</span>
<span class="fc__txt"><span class="fc__k">Email</span><span class="fc__v">[email protected]</span></span>
</a>
</address>
</div>
</footer>
</div>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실제 대비비(WCAG 2.1 1.4.3)
$ft-ink: #1b1a20; // 다크 마감 바닥
$ft-ink2: #2c2a33; // 잉크 위 카드·구분
$ft-paper: $stage-paper;
$ft-cream: #fbf6ee; // 밝은 바탕
$ft-stone: #575162; // 밝은 면 위 보조 글자 (크림 위 7.08:1)
$ft-mist: #b9b3c4; // 잉크 위 보조 글자 (8.48:1)
$ft-amber: $stage-yellow;
$ft-flame: $stage-orange; // 잉크 글자를 얹어야 하는 색 (5.21:1). 흰 글자는 3.32:1 이라 못 쓴다
$ft-teal: #479089; // 흰 면 위 5.79:1
$ft-aqua: #b3dad5;
$duration: $dur-loop; // 핀이 떨어져 튀고 카드 셋이 차례로 서는 한 주기
$easing: $ease-pop; // 핀 착지의 튐
$color: $ft-flame; // 핀·물결 색
@include stage($ft-flame);
.fc {
--drop: -26px; // 핀이 떨어지기 시작하는 높이 (지도 안쪽이라 넘침이 안 생긴다)
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-3;
width: clamp(288px, 94vw, 432px);
padding: $sp-3;
border-radius: $r-card;
background: $ft-paper;
box-shadow: $shadow-raised;
}
// 지도 — 도로는 그림이 아니라 repeating-linear-gradient 세 겹이다.
// 타일을 background-size 로 먼저 못 박고(72·36px) 그 안에 띠 폭을 넣는다.
// 정지점을 띠 폭(2px)만으로 주면 타일이 아니라 요소 전체가 반복 단위가 돼 화면이 도배된다 (269 실측)
.fc__map {
position: relative;
flex: 0 0 132px;
height: 132px;
padding: 0;
border: 0;
border-radius: $r-control;
overflow: hidden;
cursor: pointer;
background-color: $ft-cream;
background-image:
repeating-linear-gradient(0deg, rgba($ft-stone, .20) 0 6px, transparent 6px 72px),
repeating-linear-gradient(90deg, rgba($ft-stone, .16) 0 2px, transparent 2px 36px),
repeating-linear-gradient(0deg, rgba($ft-stone, .16) 0 2px, transparent 2px 36px);
background-size: 72px 72px, 36px 36px, 36px 36px;
}
.fc__map:focus-visible { outline: 3px solid $ft-teal; outline-offset: 2px; }
// 핀 — 기본 상태에서도 자기 자리에 서 있다. 그 위에 .is-demo 연출을 덧씌운다
.fc__pin {
position: absolute;
left: calc(50% - 15px);
top: 30px;
display: block;
width: 30px;
height: 30px;
color: $color;
}
.fc__pin svg { display: block; width: 100%; height: 100%; }
// 핀 끝에서 퍼지는 동그란 물결. 정지 상태에서는 자리 표시용 얇은 고리
.fc__ripple {
position: absolute;
left: calc(50% - 18px);
top: 42px;
display: block;
width: 36px;
height: 36px;
border: 2px solid $color;
border-radius: 50%;
opacity: .35;
}
.fc__side { flex: 1 1 auto; min-width: 0; }
.fc__title {
margin: 0 0 $sp-2;
color: $ft-ink;
font-weight: 800; font-size: clamp(13px, 3vw, 15px); line-height: 1.2;
font-family: inherit;
}
.fc__cards { display: flex; flex-direction: column; gap: $sp-2; font-style: normal; }
.fc__card {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-sm;
background: $ft-cream;
text-decoration: none;
}
.fc__card:focus-visible { outline: 3px solid $ft-teal; outline-offset: 2px; }
.fc__ic { display: flex; flex: 0 0 auto; color: $ft-teal; } // 크림 위 5.79:1
.fc__txt { display: flex; flex-direction: column; min-width: 0; }
.fc__k {
color: $ft-stone; // 크림 위 7.08:1
font-weight: 600; font-size: clamp(9px, 2.2vw, 10px); line-height: 1.2;
}
.fc__v {
color: $ft-ink; // 크림 위 16.06:1
font-weight: 700; font-size: clamp(11px, 2.6vw, 12px); line-height: 1.25;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
// 자동 재생 — 핀이 떨어져 튀고, 물결이 퍼지고, 카드 셋이 시차로 선다
.fc.is-demo .fc__pin,
.fc__map.is-drop .fc__pin {
animation-name: pinDrop;
animation-timing-function: $easing;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__ripple,
.fc__map.is-drop .fc__ripple {
animation-name: pinRipple;
animation-timing-function: $ease-out;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__pin, .fc.is-demo .fc__ripple {
animation-duration: $duration;
animation-iteration-count: infinite;
}
// 누름 한 번에 한 번만 — 지도 버튼을 눌렀을 때의 재생
.fc__map.is-drop .fc__pin, .fc__map.is-drop .fc__ripple {
animation-duration: 900ms;
animation-iteration-count: 1;
}
// 카드 시차 — animation: 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓰고,
// 지연 구간에 기본(완성) 상태가 번쩍이지 않게 fill-mode: backwards 를 반드시 같이 준다
.fc.is-demo .fc__card {
animation-name: cardIn;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__card:nth-child(2) { animation-delay: 220ms; }
.fc.is-demo .fc__card:nth-child(3) { animation-delay: 440ms; }
@keyframes pinDrop {
0% { transform: translateY(var(--drop)); opacity: 0; }
6% { transform: translateY(var(--drop)); opacity: 1; }
30% { transform: translateY(0); }
40% { transform: translateY(-9px); }
52%, 100% { transform: translateY(0); }
}
@keyframes pinRipple {
0%, 28% { transform: scale(.3); opacity: 0; }
34% { transform: scale(.7); opacity: .9; }
76%, 100% { transform: scale(2.4); opacity: 0; }
}
// 카드는 투명도가 아니라 clip-path 로 왼쪽부터 드러나고 왼쪽부터 걷힌다.
// ① opacity 로 띄우면 이징 곡선 위의 중간 프레임이 반투명 유령이 된다 (실측: 250ms 프레임에서 두 번째 카드 opacity .83)
// ② 한 번에 나타나고 한 번에 사라지면 그 한 장이 "가장 크게 변한 장" 이라 poster 로 뽑힌다 —
// 실제로 첫 렌더의 poster 는 메일 카드 자리가 빈 그림이었다. 조각조각 드러나면 프레임당 변화가 고르게 나뉜다
@keyframes cardIn {
0%, 6% { transform: translateY(10px); clip-path: inset(0 100% 0 0); }
26% { transform: translateY(0); clip-path: inset(0 0 0 0); }
86% { transform: translateY(0); clip-path: inset(0 0 0 0); }
99%, 100% { transform: translateY(0); clip-path: inset(0 0 0 100%); }
}
@media (max-width: 340px), (max-height: 220px) {
.fc { --drop: -14px; width: 276px; gap: $sp-2; padding: $sp-2; }
.fc__map { flex: 0 0 84px; height: 84px; }
.fc__pin { left: calc(50% - 10px); top: 18px; width: 20px; height: 20px; }
.fc__ripple { left: calc(50% - 12px); top: 26px; width: 24px; height: 24px; }
.fc__title { margin: 0 0 $sp-1; font-size: 12px; }
.fc__cards { gap: $sp-1; }
.fc__card { gap: $sp-1; padding: $sp-1 $sp-2; }
.fc__ic svg { width: 14px; height: 14px; }
.fc__k { font-size: 9px; }
.fc__v { font-size: 11px; }
}
// .fc.is-demo .fc__pin 은 (0,3,0) 이라 @media 안의 낮은 특이도로는 못 이긴다 — !important 로 끈다
@media (prefers-reduced-motion: reduce) {
.fc__pin, .fc__ripple, .fc__card {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.fc__ripple { opacity: .35 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fc {
--drop: -26px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
width: clamp(288px, 94vw, 432px);
padding: 12px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fc__map {
position: relative;
flex: 0 0 132px;
height: 132px;
padding: 0;
border: 0;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
background-color: #fbf6ee;
background-image: repeating-linear-gradient(0deg, rgba(87, 81, 98, 0.2) 0 6px, transparent 6px 72px), repeating-linear-gradient(90deg, rgba(87, 81, 98, 0.16) 0 2px, transparent 2px 36px), repeating-linear-gradient(0deg, rgba(87, 81, 98, 0.16) 0 2px, transparent 2px 36px);
background-size: 72px 72px, 36px 36px, 36px 36px;
}
.fc__map:focus-visible {
outline: 3px solid #479089;
outline-offset: 2px;
}
.fc__pin {
position: absolute;
left: calc(50% - 15px);
top: 30px;
display: block;
width: 30px;
height: 30px;
color: #ff4d1f;
}
.fc__pin svg {
display: block;
width: 100%;
height: 100%;
}
.fc__ripple {
position: absolute;
left: calc(50% - 18px);
top: 42px;
display: block;
width: 36px;
height: 36px;
border: 2px solid #ff4d1f;
border-radius: 50%;
opacity: 0.35;
}
.fc__side {
flex: 1 1 auto;
min-width: 0;
}
.fc__title {
margin: 0 0 8px;
color: #1b1a20;
font-weight: 800;
font-size: clamp(13px, 3vw, 15px);
line-height: 1.2;
font-family: inherit;
}
.fc__cards {
display: flex;
flex-direction: column;
gap: 8px;
font-style: normal;
}
.fc__card {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 8px;
background: #fbf6ee;
text-decoration: none;
}
.fc__card:focus-visible {
outline: 3px solid #479089;
outline-offset: 2px;
}
.fc__ic {
display: flex;
flex: 0 0 auto;
color: #479089;
}
.fc__txt {
display: flex;
flex-direction: column;
min-width: 0;
}
.fc__k {
color: #575162;
font-weight: 600;
font-size: clamp(9px, 2.2vw, 10px);
line-height: 1.2;
}
.fc__v {
color: #1b1a20;
font-weight: 700;
font-size: clamp(11px, 2.6vw, 12px);
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fc.is-demo .fc__pin,
.fc__map.is-drop .fc__pin {
animation-name: pinDrop;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-fill-mode: backwards;
}
.fc.is-demo .fc__ripple,
.fc__map.is-drop .fc__ripple {
animation-name: pinRipple;
animation-timing-function: ease-out;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__pin, .fc.is-demo .fc__ripple {
animation-duration: 2s;
animation-iteration-count: infinite;
}
.fc__map.is-drop .fc__pin, .fc__map.is-drop .fc__ripple {
animation-duration: 900ms;
animation-iteration-count: 1;
}
.fc.is-demo .fc__card {
animation-name: cardIn;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__card:nth-child(2) {
animation-delay: 220ms;
}
.fc.is-demo .fc__card:nth-child(3) {
animation-delay: 440ms;
}
@keyframes pinDrop {
0% {
transform: translateY(var(--drop));
opacity: 0;
}
6% {
transform: translateY(var(--drop));
opacity: 1;
}
30% {
transform: translateY(0);
}
40% {
transform: translateY(-9px);
}
52%, 100% {
transform: translateY(0);
}
}
@keyframes pinRipple {
0%, 28% {
transform: scale(0.3);
opacity: 0;
}
34% {
transform: scale(0.7);
opacity: 0.9;
}
76%, 100% {
transform: scale(2.4);
opacity: 0;
}
}
@keyframes cardIn {
0%, 6% {
transform: translateY(10px);
clip-path: inset(0 100% 0 0);
}
26% {
transform: translateY(0);
clip-path: inset(0 0 0 0);
}
86% {
transform: translateY(0);
clip-path: inset(0 0 0 0);
}
99%, 100% {
transform: translateY(0);
clip-path: inset(0 0 0 100%);
}
}
@media (max-width: 340px), (max-height: 220px) {
.fc {
--drop: -14px;
width: 276px;
gap: 8px;
padding: 8px;
}
.fc__map {
flex: 0 0 84px;
height: 84px;
}
.fc__pin {
left: calc(50% - 10px);
top: 18px;
width: 20px;
height: 20px;
}
.fc__ripple {
left: calc(50% - 12px);
top: 26px;
width: 24px;
height: 24px;
}
.fc__title {
margin: 0 0 4px;
font-size: 12px;
}
.fc__cards {
gap: 4px;
}
.fc__card {
gap: 4px;
padding: 4px 8px;
}
.fc__ic svg {
width: 14px;
height: 14px;
}
.fc__k {
font-size: 9px;
}
.fc__v {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.fc__pin, .fc__ripple, .fc__card {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.fc__ripple {
opacity: 0.35 !important;
}
}(function () {
var fc = document.querySelector('.fc');
var map = document.querySelector('.fc__map');
var pin = map.querySelector('.fc__pin');
// 격자 미리보기용 자동 재생. 사람이 손을 대면 떼고 진짜 조작으로 넘긴다
fc.classList.add('is-demo');
function stopDemo() { fc.classList.remove('is-demo'); }
['pointerdown', 'keydown'].forEach(function (t) {
document.addEventListener(t, stopDemo, { once: true });
});
// 지도를 누르면 핀이 한 번 더 떨어진다 (마우스·터치·Enter·Space 모두 click 으로 온다)
map.addEventListener('click', function () {
map.classList.remove('is-drop');
void map.offsetWidth; // 리플로를 강제해야 같은 애니메이션이 다시 시작한다
map.classList.add('is-drop');
});
pin.addEventListener('animationend', function () {
map.classList.remove('is-drop'); // 끝나면 표시를 떼서 다음 누름도 처음부터 돈다
});
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./ContactCardFooter.module.scss";
export type ContactCardFooterProps = {
/** 한 주기(ms) — 핀이 떨어져 튀고 카드 셋이 서는 시간 */
duration?: number;
/** 핀 착지의 이징 */
easing?: string;
/** 핀·물결 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/* Phosphor Duotone (MIT) — 런타임에 불러오지 않고 그대로 박아 둔다 */
function PinIcon({ size }: { size: number | string }): ReactNode {
return (
<svg viewBox="0 0 256 256" width={size} height={size} aria-hidden="true">
<g fill="currentColor">
<path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" />
<path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" />
</g>
</svg>
);
}
function PhoneIcon(): ReactNode {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M223.94 174.08A48.33 48.33 0 0 1 176 216A136 136 0 0 1 40 80a48.33 48.33 0 0 1 41.92-47.94a8 8 0 0 1 8.3 4.8l21.13 47.2a8 8 0 0 1-.66 7.53L89.32 117a7.93 7.93 0 0 0-.54 7.81c8.27 16.93 25.77 34.22 42.75 42.41a7.92 7.92 0 0 0 7.83-.59l25-21.3a8 8 0 0 1 7.59-.69l47.16 21.13a8 8 0 0 1 4.83 8.31" opacity=".2" />
<path d="m222.37 158.46l-47.11-21.11l-.13-.06a16 16 0 0 0-15.17 1.4a8 8 0 0 0-.75.56L134.87 160c-15.42-7.49-31.34-23.29-38.83-38.51l20.78-24.71c.2-.25.39-.5.57-.77a16 16 0 0 0 1.32-15.06v-.12L97.54 33.64a16 16 0 0 0-16.62-9.52A56.26 56.26 0 0 0 32 80c0 79.4 64.6 144 144 144a56.26 56.26 0 0 0 55.88-48.92a16 16 0 0 0-9.51-16.62M176 208A128.14 128.14 0 0 1 48 80a40.2 40.2 0 0 1 34.87-40a.6.6 0 0 0 0 .12l21 47l-20.67 24.74a6 6 0 0 0-.57.77a16 16 0 0 0-1 15.7c9.06 18.53 27.73 37.06 46.46 46.11a16 16 0 0 0 15.75-1.14a8 8 0 0 0 .74-.56L168.89 152l47 21.05h.11A40.21 40.21 0 0 1 176 208" />
</g>
</svg>
);
}
function MailIcon(): ReactNode {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="m224 56l-96 88l-96-88Z" opacity=".2" />
<path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z" />
</g>
</svg>
);
}
export default function ContactCardFooter({
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ff4d1f",
}: ContactCardFooterProps): ReactNode {
const [demo, setDemo] = useState(true);
const [drop, setDrop] = useState(false);
const pin = useRef<HTMLSpanElement>(null);
// 격자 미리보기용 자동 재생. 사람이 손을 대면 떼고 진짜 조작으로 넘긴다
useEffect(() => {
const off = (): void => setDemo(false);
document.addEventListener("pointerdown", off, { once: true });
document.addEventListener("keydown", off, { once: true });
return () => {
document.removeEventListener("pointerdown", off);
document.removeEventListener("keydown", off);
};
}, []);
// 지도를 누르면 핀이 한 번 더 떨어진다 (마우스·터치·Enter·Space 모두 click 으로 온다).
// 한 프레임 껐다 켜야 같은 애니메이션이 처음부터 다시 돈다
const dropAgain = useCallback((): void => {
setDrop(false);
window.requestAnimationFrame(() => setDrop(true));
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const mapClass = `${styles.map} ${drop ? styles.isDrop : ""}`;
return (
<footer className={`${styles.fc} ${demo ? styles.isDemo : ""}`} style={vars}>
<button className={mapClass} type="button" aria-label="핀 다시 떨어뜨리기" onClick={dropAgain}>
<span className={styles.ripple} aria-hidden="true" />
<span className={styles.pin} ref={pin} aria-hidden="true" onAnimationEnd={() => setDrop(false)}>
<PinIcon size="100%" />
</span>
</button>
<div className={styles.side}>
<h2 className={styles.title}>오시는 길</h2>
<address className={styles.cards}>
<a className={styles.card} href="#">
<span className={styles.ic} aria-hidden="true"><PinIcon size={18} /></span>
<span className={styles.txt}>
<span className={styles.k}>주소</span>
<span className={styles.v}>도심 큰길 12, 4층</span>
</span>
</a>
<a className={styles.card} href="tel:00000000000">
<span className={styles.ic} aria-hidden="true"><PhoneIcon /></span>
<span className={styles.txt}>
<span className={styles.k}>전화</span>
<span className={styles.v}>000-0000-0000</span>
</span>
</a>
<a className={styles.card} href="mailto:[email protected]">
<span className={styles.ic} aria-hidden="true"><MailIcon /></span>
<span className={styles.txt}>
<span className={styles.k}>메일</span>
<span className={styles.v}>[email protected]</span>
</span>
</a>
</address>
</div>
</footer>
);
}07 Contact and map card footer — Measured area 29.003% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · Website Footer Design: 9 Copy-Paste Patterns
The post this demo lives in
Website Footer Design: 9 Copy-Paste PatternsWebsite footer design is how the last band of a page is laid out — sitemap links, a signup box, contact details, a copyr See all demos in this post


