Arc Footer

A dense, many-column footer: a narrow rail with the mark and socials, four columns of grouped links split by hairlines, and a bottom line with a tagline, language links, and one action. Hovering a link places a small square beside it. Move to another link and the square does not blink across. It flies there on a shallow curve and knocks the label aside when it lands.

Sweep down a column, then jump across to another one. Switch to Blue to see the marker with an accent, Tablet and Mobile for the two-column and stacked layouts, and Full screen to see it at the bottom of a real page.

One marker, pushed labels

The original fades a separate square in and out on every link. Here there is one square for the whole footer, the same trick as the dot in this site's sidebar. The square is rendered only inside the hovered row, with a shared layoutId, so Motion's layout animation carries it from the old row to the new one.

On its own that move is a straight line. A short x/y keyframe runs on top of it and bends the path into an arc.

Try both. The straight path feels like a cursor snapping between rows. The arc reads as something being carried, and it is easy to follow when it jumps across columns.

  • The bow is sideways to the travel. Moving down a column, the square swings out to the left; moving across, it lifts over the links. The size grows with distance and is capped at the column padding, so it never leaves the footer.
  • The label is pushed, not moved. It waits until the square is 80% of the way there, then snaps 20px right on a stiff, non-bouncy spring. At that moment the square sits just left of the label and rides the last few pixels in with it.
  • The layout spring is nearly critically damped. Overshoot grows with distance, so anything bouncier sails past the target on long jumps.

Layout

  • Hairlines, not cards. The rail, columns, and bottom line are split by 1px border-border dividers, the one place this system uses borders. The dividers are worked out per column, so the same list reads as one row on wide screens, a 2 by 2 grid on tablets, and a stack on phones.
  • Quiet headings, full-strength links. Group titles are small, uppercase, and muted. The links stay text-foreground at 16px, so the marker and the push are the only hover feedback, and hover is never the only way to tell a link is a link.
  • Columns share the row equally. On wide pages every column takes an equal share with flex-1 basis-0, so the dividers stay evenly spaced from a laptop to an ultrawide screen and no empty strip collects on one side.
  • The button wipes, like the marker lands. "Book a demo" is a small square-cornered button whose fill sweeps in from the left on hover with a clip-path transition. It uses accent when one is set and a neutral mix of --primary and --muted-foreground otherwise.

Details

  • Appearing is not travelling. When the pointer comes back after leaving the footer, the square gets a fresh layoutId and pops in beside the link instead of flying in from where it last faded.
  • Leaving fades in place. When the pointer or focus leaves the footer, the square shrinks and fades where it stopped, and the label slides back.
  • Keyboard works the same. Focus drives the square too.
  • Reduced motion skips the flight and the push, and keeps a short fade.
  • Scaled previews stay accurate. Layout animations measure on screen, so the width preview above maps points back through MotionConfig's transformPagePoint. On a real page no setup is needed.
  • The marker defaults to var(--foreground). Pass accent for any CSS colour, and arc={false} for a straight flight.
  • The row gap between links is covered by a ::before on each link, so moving down a column never drops the hover between rows.

Usage

The footer is a server component; only the link columns ship JavaScript.

components/blocks/footer/arc-footer.tsx
import {
  GithubLogoIcon,
  LinkedinLogoIcon,
  CalendarDotsIcon,
  InstagramLogoIcon,
  XLogoIcon,
} from "@phosphor-icons/react/dist/ssr";

import {
  ArcFooterNav,
  type FooterGroup,
  type FooterLink,
} from "@/components/blocks/footer/arc-footer-nav";
import { cn } from "@/lib/utils";

type SocialLink = { label: string; href: string; icon: keyof typeof SOCIAL_ICONS };
type Language = { label: string; href: string; current?: boolean };

const SOCIAL_ICONS = {
  github: GithubLogoIcon,
  x: XLogoIcon,
  linkedin: LinkedinLogoIcon,
  instagram: InstagramLogoIcon,
};

const links = (...labels: string[]): FooterLink[] =>
  labels.map((label) => ({ label, href: "#" }));

const DEFAULT_COLUMNS: FooterGroup[][] = [
  [
    {
      title: "Product",
      links: links("Calendar", "Booking pages", "Team routing", "Reminders", "Insights"),
    },
    {
      title: "Learn",
      links: [
        { label: "Help center", href: "#", external: true },
        ...links("Guides", "Webinars", "What's new", "Community"),
      ],
    },
  ],
  [
    {
      title: "Made for",
      links: links("Sales teams", "Recruiting", "Customer success", "Classrooms"),
    },
    {
      title: "Connect",
      links: [
        { label: "Developer docs", href: "#", external: true },
        ...links("Embed widget", "Webhooks", "App directory"),
      ],
    },
  ],
  [
    {
      title: "Templates",
      links: links(
        "Weekly 1:1",
        "Discovery call",
        "Office hours",
        "Interview loop",
        "Onboarding session",
        "Coffee chat",
        "Round robin",
        "Group workshop",
      ),
    },
  ],
  [
    { title: "Studio", links: links("Our story", "Careers", "Press kit", "Say hello") },
    { title: "Fine print", links: links("Terms", "Privacy", "Cookies", "Accessibility") },
  ],
];

const DEFAULT_SOCIALS: SocialLink[] = [
  { label: "GitHub", href: "#", icon: "github" },
  { label: "X", href: "#", icon: "x" },
  { label: "LinkedIn", href: "#", icon: "linkedin" },
  { label: "Instagram", href: "#", icon: "instagram" },
];

const DEFAULT_LANGUAGES: Language[] = [
  { label: "English", href: "#", current: true },
  { label: "Español", href: "#" },
  { label: "Deutsch", href: "#" },
];

export function ArcFooter({
  name = "Lumora",
  href = "#",
  columns = DEFAULT_COLUMNS,
  socials = DEFAULT_SOCIALS,
  languages = DEFAULT_LANGUAGES,
  tagline = "Scheduling without the back-and-forth.",
  cta = { label: "Book a demo", href: "#" },
  arc,
  accent,
  className,
}: {
  name?: string;
  href?: string;
  /** Each column stacks one or more titled link groups. */
  columns?: FooterGroup[][];
  socials?: SocialLink[];
  languages?: Language[];
  /** A short line beside the copyright. */
  tagline?: string | null;
  cta?: FooterLink | null;
  /** Bow the marker's flight into an arc. False flies it straight. */
  arc?: boolean;
  /** Any CSS colour for the marker and the button's hover wipe. */
  accent?: string;
  className?: string;
}) {
  return (
    <footer
      className={cn(
        "@container border-t border-border bg-background text-foreground",
        className,
      )}
    >
      <div className="flex flex-col @2xl:flex-row">
        <div className="flex shrink-0 items-center border-b border-border @2xl:w-16 @2xl:flex-col @2xl:border-r @2xl:border-b-0 @2xl:pb-12">
          <div className="flex size-16 shrink-0 items-center justify-center border-r border-border @2xl:border-r-0 @2xl:border-b">
            <a
              href={href}
              aria-label={name}
              className="flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
            >
              <span className="flex size-9 items-center justify-center rounded-full bg-primary text-primary-foreground">
                <CalendarDotsIcon weight="bold" className="size-5" />
              </span>
            </a>
          </div>
          <ul className="ml-auto flex items-center px-3 @2xl:mx-0 @2xl:mt-auto @2xl:flex-col @2xl:px-0 @2xl:pt-5">
            {socials.map((social) => {
              const Icon = SOCIAL_ICONS[social.icon];
              return (
                <li key={social.label}>
                  <a
                    href={social.href}
                    aria-label={social.label}
                    className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
                  >
                    <Icon weight="fill" className="size-[18px]" />
                  </a>
                </li>
              );
            })}
          </ul>
        </div>

        <div className="flex min-w-0 flex-1 flex-col">
          <ArcFooterNav columns={columns} arc={arc} accent={accent} />

          <div className="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4 @2xl:px-11 @5xl:px-[54px]">
            <div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm leading-[1.3]">
              <p className="text-muted-foreground">
                © {new Date().getFullYear()} {name}
                {tagline && <> · {tagline}</>}
              </p>
              {languages.length > 0 && (
                <ul aria-label="Language" className="flex items-center gap-2">
                  {languages.map((language, i) => (
                    <li key={language.label} className="flex items-center gap-2">
                      {i > 0 && (
                        <span aria-hidden="true" className="text-muted-foreground">
                          ·
                        </span>
                      )}
                      <a
                        href={language.href}
                        aria-current={language.current ? "true" : undefined}
                        className={cn(
                          "rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
                          language.current
                            ? "font-medium text-foreground"
                            : "text-muted-foreground transition-colors duration-150 hover:text-foreground",
                        )}
                      >
                        {language.label}
                      </a>
                    </li>
                  ))}
                </ul>
              )}
            </div>
            {cta && (
              <a
                href={cta.href}
                className="group/cta relative inline-flex h-9 shrink-0 items-center overflow-hidden rounded-[4px] bg-primary px-4 text-[15px] font-semibold tracking-[0.01em] text-primary-foreground outline-none transition-transform duration-150 ease-snappy focus-visible:ring-2 focus-visible:ring-ring/50 active:scale-[0.97]"
              >
                {/* Wipes in from the left on hover, like the marker arriving. */}
                <span
                  aria-hidden="true"
                  className="absolute inset-0 [clip-path:inset(0_100%_0_0)] transition-[clip-path] duration-300 ease-snappy group-hover/cta:[clip-path:inset(0)] motion-reduce:transition-none"
                  style={{
                    backgroundColor:
                      accent ?? "color-mix(in oklch, var(--primary), var(--muted-foreground))",
                  }}
                />
                <span className="relative">{cta.label}</span>
              </a>
            )}
          </div>
        </div>
      </div>
    </footer>
  );
}
"use client";

import { ArrowUpRightIcon } from "@phosphor-icons/react";
import { motion, useReducedMotion, type Transition } from "motion/react";
import { useId, useRef, useState } from "react";

import { cn } from "@/lib/utils";

export type FooterLink = { label: string; href: string; external?: boolean };
export type FooterGroup = { title: string; links: FooterLink[] };

// Flight time (s) of the marker's bow. The layout spring below settles in
// roughly the same window, so the two stay in step.
const FLIGHT = 0.35;
// Moment in the flight (0-1) when the marker reaches the label and pushes it.
const IMPACT = 0.8;
// Near-critically damped: overshoot grows with distance, so anything bouncier
// visibly sails past the target on long jumps across the footer.
const MARKER_SPRING = { type: "spring", stiffness: 800, damping: 52 } as const;

// How far (px) the marker bows away from a straight line while travelling.
// Scales with distance so short hops barely bend and long jumps swing wide.
// Capped at the column padding so a sideways swing never leaves the footer.
const BOW_MIN = 6;
const BOW_MAX = 24;
const BOW_PER_PX = 0.08;

// Where the marker sits (px) at the moment of impact: just left of the
// label, which is still at rest until the hit shoves it right. The marker
// then follows it in, so it never draws over the text.
const TOUCH_OFFSET = -5;

// The active label sits shoved to the right of the marker. On a hit it snaps
// out quickly and settles without bouncing; on leaving it slides back.
const LABEL_SHIFT = 20;
const LABEL_PUSH = { type: "spring", stiffness: 1000, damping: 60 } as const;
const LABEL_RETURN = { type: "spring", stiffness: 900, damping: 45 } as const;

type Point = { x: number; y: number };

const STILL: Point = { x: 0, y: 0 };

type Hover = {
  id: string;
  /** Bumped each time the marker reappears, so it mounts in place instead of flying in. */
  visit: number;
  /** Peak offset of the flight, perpendicular to travel. Zero means no travel. */
  bow: Point;
  /** The pointer or focus left the footer; the marker fades where it stopped. */
  hidden: boolean;
};

/** Bow sideways from the line of travel: up for moves across, left for moves down. */
function bowFor(dx: number, dy: number): Point {
  const distance = Math.hypot(dx, dy);
  if (distance < 1) return STILL;
  let nx = -dy / distance;
  let ny = dx / distance;
  if (ny > 0.01 || (Math.abs(ny) <= 0.01 && nx > 0)) {
    nx = -nx;
    ny = -ny;
  }
  const size = Math.min(BOW_MAX, BOW_MIN + distance * BOW_PER_PX);
  return { x: nx * size, y: ny * size };
}

function Marker({
  layoutId,
  bow,
  hidden,
  accent,
}: {
  layoutId: string;
  bow: Point;
  hidden: boolean;
  accent: string;
}) {
  const reduceMotion = useReducedMotion();
  const travelling = !reduceMotion && (bow.x !== 0 || bow.y !== 0);
  const flight: Transition = {
    duration: FLIGHT,
    times: [0, 0.3, IMPACT, 1],
    ease: ["easeOut", "easeInOut", "easeOut"],
  };

  return (
    <motion.span
      layoutId={layoutId}
      aria-hidden="true"
      // A travelling marker takes over from the one it replaces; a fresh one
      // pops in on the spot.
      initial={travelling ? false : { opacity: 0, scale: 0.4 }}
      // The shared-layout projection moves the marker in a straight line
      // between links; synced x/y keyframes on top bend that path into an arc.
      animate={{
        opacity: hidden ? 0 : 1,
        scale: hidden ? 0.4 : 1,
        x: travelling ? [0, bow.x, TOUCH_OFFSET, 0] : 0,
        y: travelling ? [0, bow.y, 0, 0] : 0,
      }}
      transition={
        reduceMotion
          ? { layout: { duration: 0 }, default: { duration: 0.15 } }
          : {
              layout: MARKER_SPRING,
              x: flight,
              y: flight,
              opacity: { duration: 0.15 },
              scale: { type: "spring", visualDuration: 0.25, bounce: 0.3 },
            }
      }
      className="pointer-events-none absolute top-[calc(50%-3.5px)] left-0 size-[7px]"
      style={{ backgroundColor: accent }}
    />
  );
}

/**
 * Dividers between columns: stacked on phones, a two-column grid on tablets, and
 * one row from wide containers up. Worked out per index so any column count
 * gets the right hairlines.
 */
function columnBorders(i: number, count: number) {
  const lastRow = count % 2 === 0 ? i >= count - 2 : i === count - 1;
  const last = i === count - 1;
  return cn(
    !last && "border-b",
    "@xl:border-b-0",
    !lastRow && "@xl:border-b",
    i % 2 === 0 && !last && "@xl:border-r",
    "@5xl:border-b-0",
    !last && "@5xl:border-r",
  );
}

/**
 * Link columns with one shared marker. Hovering or focusing a link flies the
 * marker there on a shallow arc, and when it lands it shoves the label right.
 */
export function ArcFooterNav({
  columns,
  arc = true,
  accent = "var(--foreground)",
  className,
}: {
  /** Each column stacks one or more titled groups. */
  columns: FooterGroup[][];
  /** Bow the marker's flight into an arc. False flies it in a straight line. */
  arc?: boolean;
  /** Any CSS colour for the marker. */
  accent?: string;
  className?: string;
}) {
  const reduceMotion = useReducedMotion();
  // The footer can render more than once on a page; keep each marker's
  // shared-layout animation scoped to its own instance.
  const markerId = useId();
  const navRef = useRef<HTMLElement>(null);
  const lastPoint = useRef<Point | null>(null);
  const [hover, setHover] = useState<Hover | null>(null);

  function show(el: HTMLElement, id: string) {
    const nav = navRef.current;
    if (!nav) return;
    // Measured relative to the nav and divided by its scale, so scrolling and
    // CSS-scaled previews don't skew the bow.
    const navRect = nav.getBoundingClientRect();
    const rect = el.getBoundingClientRect();
    const scale = navRect.width / nav.offsetWidth || 1;
    const point = {
      x: (rect.left - navRect.left) / scale,
      y: (rect.top + rect.height / 2 - navRect.top) / scale,
    };
    const from = lastPoint.current;
    lastPoint.current = point;

    if (hover && !hover.hidden && hover.id === id) return;
    if (!hover || hover.hidden || !from) {
      setHover({ id, visit: (hover?.visit ?? 0) + 1, bow: STILL, hidden: false });
      return;
    }
    setHover({
      id,
      visit: hover.visit,
      bow: arc ? bowFor(point.x - from.x, point.y - from.y) : STILL,
      hidden: false,
    });
  }

  function hide() {
    setHover((current) => (current ? { ...current, hidden: true } : current));
  }

  const travelling = hover !== null && (hover.bow.x !== 0 || hover.bow.y !== 0);

  return (
    <nav
      ref={navRef}
      aria-label="Footer"
      className={cn(
        "flex flex-col @xl:grid @xl:grid-cols-2 @5xl:flex @5xl:flex-row",
        className,
      )}
      onPointerLeave={hide}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget)) hide();
      }}
    >
      {columns.map((groups, c) => (
        <div
          key={c}
          className={cn(
            "flex flex-col gap-10 border-border px-6 py-10 @xl:gap-[50px] @xl:py-16 @xl:pr-5 @xl:pl-11",
            // Equal shares of the row, so the dividers spread evenly at any width.
            "@5xl:min-w-0 @5xl:flex-1 @5xl:basis-0 @5xl:first:pl-[54px]",
            columnBorders(c, columns.length),
          )}
        >
          {groups.map((group) => (
            <div key={group.title}>
              <h2 className="mb-[22px] text-sm leading-[1.7] text-pretty text-muted-foreground uppercase">
                {group.title}
              </h2>
              <ul className="flex flex-col gap-2.5">
                {group.links.map((link) => {
                  const id = `${group.title}/${link.label}`;
                  const current = hover?.id === id;
                  const active = current && !hover.hidden;
                  const layoutId = `${markerId}-${hover?.visit}`;
                  return (
                    <li key={link.label} className="relative">
                      {current && (
                        <Marker
                          key={layoutId}
                          layoutId={layoutId}
                          bow={hover.bow}
                          hidden={hover.hidden}
                          accent={accent}
                        />
                      )}
                      <a
                        href={link.href}
                        {...(link.external && { target: "_blank", rel: "noreferrer" })}
                        onPointerEnter={(e) => show(e.currentTarget, id)}
                        onFocus={(e) => show(e.currentTarget, id)}
                        // The ::before stretches the hit area over the row gaps,
                        // so moving down a column never drops the hover.
                        className="relative flex w-fit rounded-sm text-base leading-[1.45] tracking-[0.01em] text-foreground outline-none before:absolute before:inset-x-0 before:-inset-y-[5px] focus-visible:ring-2 focus-visible:ring-ring/50"
                      >
                        <motion.span
                          className="inline-flex items-center gap-1"
                          initial={false}
                          animate={{ x: active ? LABEL_SHIFT : 0 }}
                          transition={
                            reduceMotion
                              ? { duration: 0 }
                              : active
                                ? // Wait for the marker to arrive so the label
                                  // reads as pushed, not self-moving.
                                  { ...LABEL_PUSH, delay: travelling ? FLIGHT * IMPACT : 0 }
                                : LABEL_RETURN
                          }
                        >
                          {link.label}
                          {link.external && (
                            <ArrowUpRightIcon aria-hidden="true" className="size-3.5" />
                          )}
                        </motion.span>
                      </a>
                    </li>
                  );
                })}
              </ul>
            </div>
          ))}
        </div>
      ))}
    </nav>
  );
}

Resources

Search concepts

Search for a command to run...