Website Footer Design: 9 Copy-Paste Patterns
Website footer design is how the last band of a page is laid out — sitemap links, a signup box, contact details, a copyright line — and these nine patterns
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Multi-column sitemap footer
- 02 Newsletter-integrated footer
- 03 Minimal one-line footer
- 04 Sticky bottom app bar
- 05 Social tile footer
- 06 Legal link-tree footer
- 07 Contact and map card footer
- 08 Wavy-edge footer
- 09 Dark-ink contrast footer
The nine follow the order in which a visitor actually meets a footer, not a popularity ranking. First the full map of the site (01), then the one thing the site wants back from you (02), then the smallest possible way to close a page (03). Next come the bars and tiles that live at the bottom of a phone screen (04, 05), then the ladder of links and legal pages a profile-style site ends on (06), then the real-world address block (07). The last two treat the footer's own edge and floor as the design: a wave that hands the page over to the dark band (08), and the dark band itself catching the light sheet above it (09). Every measurement below comes from the same 24-frame render of each demo at 480×300, and none of the nine overflowed its stage at desktop or phone width.
01Multi-column sitemap footer
Three link groups stand side by side on a wide screen, and under 340px wide or 220px tall each group folds into an accordion whose caret rotates as the list opens. Reach for it when the footer has to carry the whole sitemap. The open and close run on a grid-template-rows animation from 0fr to 1fr — plain height: auto does not interpolate, but a grid track does — and the panel moved in 12 of 24 frames for 2.053% cumulative changed area.
.fsm__panel {
display: grid;
grid-template-rows: 1fr;
overflow: hidden;
transition: grid-template-rows $dur-base $easing;
}
.fsm__panel > * { min-height: 0; }
.fsm__col:has(.fsm__btn[aria-expanded="false"]) .fsm__panel { grid-template-rows: 0fr; }
02Newsletter-integrated footer
Entering an email address and pressing send pushes the button down 3px, swaps the paper plane for a spinner, then stamps in a checkmark while a thin bar fills from scaleX(0) to scaleX(1). It suits any footer whose real job is collecting one address. The three icons are stacked in a single grid cell so the button never changes width as the state changes, and the loop registered 11 animations across 10 moving frames at intensity 109.8.
.fn__icons {
display: grid;
place-items: center;
width: 18px;
height: 18px;
}
.fn__ic {
grid-area: 1 / 1;
display: grid;
place-items: center;
opacity: 0;
}
.fn.is-idle .fn__ic--plane,
.fn.is-error .fn__ic--plane { opacity: 1; }
.fn.is-sending .fn__ic--load { opacity: 1; }
.fn.is-done .fn__ic--done { opacity: 1; }
03Minimal one-line footer
A wordmark, a copyright line, and three round social dots hold one row at every width, and a hovered or focused dot rises 6px and floods with its own brand color. This is the one to paste into a portfolio where the footer should take up as little room as possible. It is the quietest of the nine — 1.868% cumulative changed area — yet the busiest in sampling terms, with 18 of 24 frames showing movement at intensity 140.0.
.fo__dot {
--on: #{$ft-flame};
--on-ink: #{$ft-ink};
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: $ft-paper;
color: $ft-stone;
transition: transform $dur-quick $easing, background-color $dur-quick $ease-out, color $dur-quick $ease-out;
}
.fo__dot--b { --on: #{$ft-ink}; --on-ink: #{$ft-paper}; }
.fo__dot--c { --on: #{$ft-teal}; --on-ink: #{$ft-paper}; }
.fo__dot:hover,
.fo__dot:focus-visible {
transform: translateY(-6px);
background: var(--on);
color: var(--on-ink);
}
04Sticky bottom app bar
A bar sticks to the bottom of a scrolling app screen, and the aqua pill slides between four slots while the arriving icon pops up 5px and scales to 1.16. Use it for a mobile web app where the bottom row is navigation rather than a footer. The pill is one slot wide at width: 25%, so a single translateX(100%) is exactly one slot and nothing has to be measured in pixels; the whole cycle changed 9.898% of the frame across 18 moving frames.
.dk__nav {
position: relative;
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.dk__pill {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 25%; // one slot = 100%, so translateX(100%) is exactly one slot
height: 100%;
border-radius: $r-control;
background: $color;
transform: translateX(calc(var(--n, 0) * 100%));
transition: transform $dur-base $ease-spring;
}
05Social tile footer
Four large tiles take turns lifting 6px out of a pressed shadow into a raised one, the lifted tile's glyph switching to its account color while a small arrow scales up from nothing in the corner. Pick it when following the account is the point of the footer, not reading the links. Lifting takes 5% of the loop and settling takes 9%, which is deliberate — 13.897% cumulative changed area over 19 moving frames, weighted toward the lift.
.sc__go {
position: absolute;
top: 8px;
right: 8px;
display: block;
color: $ft-teal;
transform: scale(0);
transform-origin: top right;
transition: transform $dur-quick $easing;
}
@keyframes tileLift {
0% { transform: translateY(0); box-shadow: $shadow-press; }
5%, 16% { transform: translateY(-6px); box-shadow: $shadow-raised; }
25%, 100% { transform: translateY(0); box-shadow: $shadow-press; }
}
06Legal link-tree footer
The more important a link, the bigger its step on the staircase: lead links run up to 26px, secondary ones to 17px, and the terms and policy line sits small underneath. Hovering or focusing nudges the link 6px to the right while an underline fills from the left. The underline is a scaleX on a pseudo-element rather than an animated width, so no layout is recalculated — 3.451% changed area across 19 moving frames.
.lt__link::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transform-origin: left center;
transition: transform $dur-quick $easing;
}
.lt__link:hover,
.lt__link:focus-visible { transform: translateX(6px); }
.lt__link:hover::after,
.lt__link:focus-visible::after { transform: scaleX(1); }
07Contact and map card footer
A pin falls into the map card, bounces back up 9px once, and a ring ripples out to 2.4× and fades while the address, phone, and mail cards wipe in, 220ms apart. It belongs at the bottom of any store or office page that ends with directions. There is no map image and no network request: the streets are three repeating-linear-gradient layers over a cream base, and this is the widest-moving of the nine at 29.003% cumulative changed area over 20 frames.
.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;
}
08Wavy-edge footer
The seam between the cream body and the ink footer is two wave layers drifting left at different speeds, the dark front layer covering four tiles per cycle and the pale back layer two. Use it wherever a section color change would otherwise land as a hard horizontal line. Both waves are 120×28 SVG paths inlined as data: URIs, so the effect costs zero network requests, and it moved in 23 of 24 frames — the steadiest loop in the set at intensity 149.1.
.wv__wave {
position: absolute;
top: 0;
left: 0;
display: block;
width: 960px; // two 480px sets of four 120px tiles
height: 28px;
background-repeat: repeat-x;
background-size: 120px 28px;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-duration: $duration;
}
@keyframes waveBack { from { transform: translateX(0); } to { transform: translateX(-240px); } }
@keyframes waveFront { from { transform: translateX(0); } to { transform: translateX(-480px); } }
09Dark-ink contrast footer
The cream sheet drops onto the ink floor, overshoots 3px, settles, and then four aqua underlines scan one after another across the links, 180ms apart. This is the closing frame for a light-toned site that needs weight at the bottom. Cream on ink measures 16.06:1 and white on ink 17.28:1, which is why the sheet edge reads as a hard line rather than a gradient; 14.908% changed area over 22 moving frames.
.ic__sheet {
padding: $sp-4;
border-radius: $r-card;
background: $ft-cream;
text-align: center;
box-shadow: $shadow-raised;
}
@keyframes sheetDrop {
0% { transform: translateY(var(--lift)); }
22% { transform: translateY(0); }
30% { transform: translateY(-3px); }
38%, 100% { transform: translateY(0); }
}
@keyframes ruleScan {
0% { transform: scaleX(0); }
24%, 60% { transform: scaleX(1); }
84%, 100% { transform: scaleX(0); }
}
Where it breaks — the trap
The first wall was vertical. The phone render is 320×200, and after the stage's own 12.8px padding on the top and bottom there are exactly 174.4px left for the footer. The first build of 01 stacked each column's links vertically on a narrow screen and came back at 176.4px — two pixels over. The reflex fix, stacking things when the screen gets small, is what caused it: stacking trades width you have for height you do not. Laying the link lists out with flex-direction: row; flex-wrap: wrap and trimming the spacing brought it to 160px, and the rule that came out of it is that you never raise a value inside a narrow-screen media query, only lower it. Two more decisions came off the same 174.4px budget: the caret moves to the far end of the header with justify-content: space-between so each column reads as one full-width accordion bar, and the third link in each group is dropped with display: none instead of being allowed to wrap onto a line nobody has room for.
The second is about which frame becomes the thumbnail. The poster is picked as the frame that changed most from the one before it, which means an exit can beat an entrance. The contact cards in 07 first faded in with opacity, and on a cubic-bezier(.2, .8, .2, 1) curve the value is already 0.827 at 30% of its window, so a half-transparent ghost frame was guaranteed somewhere in the 24-frame sample. Worse, the entrance frame's changed area of 14,625px beat the next frame's 13,669px, so the frame chosen as the poster was the one still missing the third card. Replacing the fade with a clip-path: inset() wipe spread the change evenly across frames, and the poster became the frame that has all three cards, the pin, and the ripple. Item 06 had the same disease in miniature: the underline filled in 9% of the loop and retracted in 5%, so the retraction was the bigger change and the thumbnail showed a bare link. Swapping those to 5% fill and 9% retract made the frame with the underline win.
The third is arithmetic. A seamless wave only works if it shifts by whole tiles. Playwright reported the wave track in 08 at an offsetWidth of 960px, the tile is 120px, and one set is 480px — exactly four tiles. The front layer shifts −480px per cycle and the back layer −240px, both whole multiples, and screenshot hashes taken at translateX 0, −480px, and −240px came back identical while −479px differed. That last comparison is the proof: the loop lands on the same pixels, so there is no seam to see.
The fourth cost a whole navigation slot. In 04 the active label turns ink so it can be read on the aqua pill, and while the pill traveled away, an ink label left sitting on the ink bar simply disappeared — the slot looked empty. Nor was the pill's arrival the end of its keyframe: cubic-bezier(.2, 1.4, .4, 1) overshoots, and Playwright showed the pill already parked at 52% of its [46, 58]% window, so the color cut had to be moved to the measured arrival time rather than the keyframe boundary. The pill's width was never guessed either — a four-column grid put the nav at 312px and each slot at 78px, the pill measured 78px, and the tab positions came back at 84, 162, 240, and 318px, an exact match. The archive you unlock with 7g7q5d5z carries all nine at these corrected values, so none of this has to be rediscovered.
Accessibility
Contrast was computed with the WCAG 2.1 relative-luminance formula for every pairing used in the nine, and two pairings were computed, failed, and then kept out of the demos entirely.
| Foreground | Background | Measured ratio | Body-text bar |
|---|---|---|---|
| White #ffffff | Ink #1b1a20 | 17.28:1 | Pass |
| Amber #ffd23f | Ink #1b1a20 | 11.97:1 | Pass |
| Aqua #6ee7d7 | Ink #1b1a20 | 11.58:1 | Pass |
| Mist #b9b3c4 | Ink #1b1a20 | 8.48:1 | Pass |
| Ink #1b1a20 | Cream #fbf6ee | 16.06:1 | Pass |
| Stone #575162 | Cream #fbf6ee | 7.08:1 | Pass |
| White #ffffff | Teal #0b7269 | 5.79:1 | Pass |
| Ink #1b1a20 | Orange #ff4d1f | 5.21:1 | Pass |
| White #ffffff | Orange #ff4d1f | 3.32:1 | Fails — never used |
| White #ffffff | Amber #ffd23f | 1.44:1 | Fails — never used |
That is the reason every word on the orange stage of 05 and 07 is ink rather than white, and why the amber stage of 06 carries ink text only. The minimum body-text bar and the way these numbers are derived are set out in the W3C's contrast guidance.
Under prefers-reduced-motion: reduce all nine drop the motion and keep the state and the information. In 01 the panels and carets stop animating, but aria-expanded still decides which column is open. In 02 the icons, messages, button, bar, and spinner all stop, while the result sentence still appears in the live region. In 03 the autoplay loop stops, and hover and focus still recolor a dot instantly, the 6px lift included. In 04 the pill freezes and the rule .is-demo .dk__tab.is-on { color: $ft-ink; } keeps the active slot readable. In 05 the tiles stop lifting but hover and focus still scale the corner arrow to 1. In 06 the underline on [aria-current="page"] stays filled at scaleX(1) so the current page is still marked. In 07 the pin, ripple, and all three cards are locked to opacity: 1 and transform: none, so nothing is mid-entrance. In 08 the two waves simply stop, shape intact. In 09 the sheet and the scan lines stop and the layout holds. Every one of those blocks needs !important, because the autoplay selectors carry a (0, 3, 0) specificity that a bare rule inside a media query loses to.
The markup that carries all of this is there to be checked. Each of the nine is a real <footer> element, and 01, 02, and 03 spell out role="contentinfo" on top of the implicit landmark. Seven of them label a <nav> — 01 labels one per column, 06 separates a main list from a legal list, and 03, 04, 05, 08, and 09 label a single one — while 02 is a <form> and 07 wraps its details in <address>. The accordion in 01 uses aria-expanded with aria-controls, 04 and 06 mark the current destination with aria-current="page", 02 announces its result through role="status" and aria-live="polite" following MDN's live regions guide, and 08's pause button is a real aria-pressed toggle. Keyboard paths are wired in code, not implied: 01 moves between column headers with the up and down arrows, and 04 runs a roving tabIndex so Tab enters the bar once and the left and right arrows move the pill from there. All nine define :focus-visible outlines, and the 0fr to 1fr track animation follows MDN's grid-template-rows reference.
Neighboring pieces of a page live in the template collection, and the about page explains how each demo here gets measured before it ships.
FAQ
Do these nine work without any JavaScript?
Seven of them do. The motion in 01, 03, 05, 06, 07, 08, and 09 is CSS animation plus hover and focus rules, so pasting the HTML and the compiled CSS is enough. Only 02 needs script for the send-and-confirm sequence, and 04 needs it for the roving tabIndex and the arrow keys — 01's accordion toggle runs on script as well, but the footer ships with every panel open, so nothing is hidden without it — and even there the visible states are CSS classes, so you can wire them to your own framework instead.
Why is the wave in 08 an inline SVG background instead of an image file?
Because a footer edge that arrives a moment after the page does looks broken. The wave is a 120×28 path encoded as a data: URI inside the stylesheet, which means zero extra requests, no flash before it loads, and a color you change by editing one hex value in the SCSS rather than re-exporting an asset.
How do I make one of these the actual bottom of my site?
Drop the .stage wrapper the demo uses to center itself on the preview canvas, put the pattern's own root element at the end of your page, and let it span the full width. The narrow-screen rules are written against max-width: 340px and max-height: 220px, so raise those breakpoints to your real phone width before you judge how it looks.