# Footer Wordmark Rise

> A dark site footer that rises into view over a glow swelling off the bottom edge, closed by a giant wordmark fitted to the column whose letters climb out of their masks one by one.

A shadcn/ui block from the [@tween-ui](https://tween-ui.vercel.app) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/tween-ui/footer-wordmark-rise).

- Page, with a live preview: https://shadcn.exchange/r/tween-ui/footer-wordmark-rise
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/tween-ui/footer-wordmark-rise-light-4498df991bb52d86.png
- Registry item: https://tween-ui.vercel.app/r/footer-wordmark-rise.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://tween-ui.vercel.app/r/footer-wordmark-rise.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@gsap/react`, `gsap`

## Notes from the registry

Docs, props and examples: https://tween-ui.vercel.app/block/footer-wordmark-rise
GSAP plugins (ScrollTrigger, SplitText) are registered inside the component, so there is nothing to set up.
The wordmark is sized from the column, not from a font size, so any brand name spans the footer edge to edge. Pass your own `columns`, `socials` and `legal` links.

## Files

### `components/tweenui/footer-wordmark-rise.tsx`

```tsx
'use client';

import { useId, useRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
import { cn } from '@/lib/utils';

gsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);

const prefersReducedMotion = () =>
  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

export type FooterSocialIcon = 'x' | 'instagram' | 'linkedin' | 'dribbble' | 'github';

export interface FooterLink {
  label: string;
  href: string;
}

export interface FooterColumn {
  heading: string;
  links: FooterLink[];
}

export interface FooterSocial {
  label: string;
  href: string;
  icon: FooterSocialIcon;
}

export interface FooterWordmarkRiseProps extends ComponentPropsWithoutRef<'footer'> {
  brand?: string;
  logo?: ReactNode;
  description?: string;
  columns?: FooterColumn[];
  socials?: FooterSocial[];
  legal?: FooterLink[];
  copyright?: string;
}

const REPO = 'https://github.com/StaticMania/tween-ui';

const DEFAULT_COLUMNS: FooterColumn[] = [
  {
    heading: 'Components',
    links: [
      { label: 'All components', href: '/components' },
      { label: 'Icon Trail Button', href: '/component/icon-trail-button' },
      { label: 'Sliding Tabs', href: '/component/sliding-tabs' },
      { label: 'Number Counter', href: '/component/number-counter' },
    ],
  },
  {
    heading: 'Blocks',
    links: [
      { label: 'All blocks', href: '/components#blocks' },
      { label: 'Kinetic Type Ring', href: '/block/kinetic-type-ring' },
      { label: 'Pricing Plan Switch', href: '/block/pricing-plan-switch' },
      { label: 'Shutter Slider', href: '/block/shutter-slider' },
    ],
  },
  {
    heading: 'Community',
    links: [
      { label: 'GitHub', href: REPO },
      { label: 'Report an issue', href: `${REPO}/issues` },
    ],
  },
];

const DEFAULT_SOCIALS: FooterSocial[] = [{ label: 'GitHub', href: REPO, icon: 'github' }];

const DEFAULT_LEGAL: FooterLink[] = [
  { label: 'MIT License', href: `${REPO}/blob/main/LICENSE` },
  { label: 'Changelog', href: `${REPO}/blob/main/CHANGELOG.md` },
  { label: 'Contributing', href: `${REPO}/blob/main/CONTRIBUTING.md` },
];

const TWEEN_MARK = (
  <svg aria-hidden="true" viewBox="0 0 24 24" className="size-7">
    <rect width="24" height="24" rx="6" fill="#045f64" />
    <path
      d="M5 19 C 9.5 8.9, 5 5, 19 5"
      fill="none"
      stroke="#c6f56f"
      strokeWidth="2"
      strokeLinecap="round"
    />
    <circle cx="19" cy="5" r="1.8" fill="#c6f56f" />
  </svg>
);

const EASE_OUT = 'ease-[cubic-bezier(0.16,1,0.3,1)]';

const underline = cn(
  "after:pointer-events-none after:absolute after:-bottom-0.5 after:left-0 after:h-px after:w-full after:bg-current after:content-['']",
  'after:origin-right after:scale-x-0 after:transition-transform after:duration-500',
  'after:ease-[cubic-bezier(0.16,1,0.3,1)]',
  'hover:after:origin-left hover:after:scale-x-100 focus-visible:after:origin-left focus-visible:after:scale-x-100',
  'motion-reduce:after:transition-none'
);

const focusRing =
  'rounded-xs focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#c6f56f]';

const SOCIAL_PATHS: Record<FooterSocialIcon, ReactNode> = {
  x: (
    <path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />
  ),
  instagram: (
    <>
      <rect width="20" height="20" x="2" y="2" rx="5" ry="5" />
      <path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
      <line x1="17.5" x2="17.51" y1="6.5" y2="6.5" />
    </>
  ),
  linkedin: (
    <>
      <path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
      <rect width="4" height="12" x="2" y="9" />
      <circle cx="4" cy="4" r="2" />
    </>
  ),
  dribbble: (
    <>
      <circle cx="12" cy="12" r="10" />
      <path d="M19.13 5.09C15.22 9.14 10 10.44 2.25 10.94" />
      <path d="M21.75 12.84c-6.62-1.41-12.14 1-16.38 6.32" />
      <path d="M8.56 2.75c4.37 6 6 9.42 8 17.72" />
    </>
  ),
  github: (
    <>
      <path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
      <path d="M9 18c-4.51 2-5-2-7-2" />
    </>
  ),
};

const isExternal = (href: string) => /^https?:/.test(href);

const externalProps = (href: string) =>
  isExternal(href) ? { target: '_blank', rel: 'noopener noreferrer' } : {};

function RollLabel({ label }: { label: string }) {
  return (
    <>
      <span className="sr-only">{label}</span>
      <span aria-hidden="true" className="block overflow-hidden leading-[1.25]">
        {Array.from(label).map((char, index) => (
          <span
            key={`${char}-${index}`}
            className={cn(
              'inline-block whitespace-pre [text-shadow:0_1.25em_currentColor]',
              'transition-transform duration-500 group-hover/link:-translate-y-full group-focus-visible/link:-translate-y-full',
              EASE_OUT,
              'motion-reduce:transition-none motion-reduce:group-hover/link:translate-y-0 motion-reduce:group-focus-visible/link:translate-y-0'
            )}
            style={{ transitionDelay: `${index * 18}ms` }}
          >
            {char}
          </span>
        ))}
      </span>
    </>
  );
}

function LinkGroup({ column }: { column: FooterColumn }) {
  const headingId = useId();

  return (
    <div className="grid content-start gap-4">
      <p id={headingId} className="text-xs tracking-[0.14em] text-[#9fd4d6]/60 uppercase">
        {column.heading}
      </p>
      <ul aria-labelledby={headingId} className="grid gap-3">
        {column.links.map((link) => (
          <li key={`${link.label}-${link.href}`}>
            <a
              href={link.href}
              {...externalProps(link.href)}
              className={cn(
                'group/link relative inline-block text-[15px] whitespace-nowrap text-[#e8f3f2]/85 transition-colors duration-300 hover:text-[#c6f56f] focus-visible:text-[#c6f56f] motion-reduce:transition-none',
                underline,
                focusRing
              )}
            >
              <RollLabel label={link.label} />
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default function FooterWordmarkRise({
  brand = 'Tween UI',
  logo = TWEEN_MARK,
  description = 'GSAP & CSS animated components for React. Copy the source, own the animation.',
  columns = DEFAULT_COLUMNS,
  socials = DEFAULT_SOCIALS,
  legal = DEFAULT_LEGAL,
  copyright,
  className,
  ...props
}: FooterWordmarkRiseProps) {
  const rootRef = useRef<HTMLElement>(null);
  const boxRef = useRef<HTMLDivElement>(null);
  const markRef = useRef<HTMLParagraphElement>(null);
  const wordRef = useRef<HTMLSpanElement>(null);

  useGSAP(
    () => {
      const root = rootRef.current;
      const box = boxRef.current;
      const mark = markRef.current;
      const word = wordRef.current;
      if (!root || !box || !mark || !word) return;

      const reduce = prefersReducedMotion();

      let fittedFor = 0;
      const fit = () => {
        const width = box.clientWidth;
        if (!width || width === fittedFor) return;
        fittedFor = width;
        mark.style.fontSize = '';
        const size = parseFloat(getComputedStyle(mark).fontSize);
        const trailing = parseFloat(getComputedStyle(word).letterSpacing) || 0;
        const ink = word.offsetWidth - trailing;
        if (ink > 0) mark.style.fontSize = `${(size * width) / ink}px`;
      };
      const observer = new ResizeObserver(fit);
      observer.observe(box);
      fit();

      const entrance = reduce
        ? null
        : gsap
            .timeline({
              scrollTrigger: {
                trigger: root,
                start: 'top 85%',
                toggleActions: 'play none none reverse',
              },
            })
            .fromTo(
              root.querySelectorAll('[data-footer-glow]'),
              { autoAlpha: 0, scaleY: 0.35 },
              { autoAlpha: 1, scaleY: 1, duration: 1.6, ease: 'power2.out' },
              0
            )
            .fromTo(
              root.querySelectorAll('[data-footer-rise]'),
              { autoAlpha: 0, y: 48 },
              { autoAlpha: 1, y: 0, duration: 1.1, ease: 'expo.out', stagger: 0.18 },
              0.1
            );

      if (!reduce) gsap.set(word, { autoAlpha: 0 });

      let split: SplitText | null = null;
      let reveal: gsap.core.Tween | null = null;
      let isAlive = true;

      const revealWord = () => {
        split = SplitText.create(word, {
          type: 'chars',
          charsClass: 'fwr-char',
          mask: 'chars',
          aria: 'none',
        });
        mark.dataset.split = '';
        reveal = gsap.fromTo(
          split.chars,
          { yPercent: 105 },
          {
            yPercent: 0,
            duration: 1.6,
            ease: 'power3.out',
            stagger: 0.12,
            scrollTrigger: {
              trigger: box,
              start: 'top 90%',
              toggleActions: 'play none none reverse',
            },
          }
        );
        gsap.set(word, { autoAlpha: 1 });
      };

      const setup = () => {
        if (!isAlive) return;
        fittedFor = 0;
        fit();
        if (reduce) return;
        revealWord();
        fittedFor = 0;
        fit();
        ScrollTrigger.refresh();
      };

      const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;
      Promise.race([fonts, new Promise((resolve) => setTimeout(resolve, 1500))]).then(setup);

      return () => {
        isAlive = false;
        observer.disconnect();
        entrance?.scrollTrigger?.kill();
        entrance?.kill();
        reveal?.scrollTrigger?.kill();
        reveal?.kill();
        split?.revert();
        delete mark.dataset.split;
        mark.style.fontSize = '';
      };
    },
    { scope: rootRef, dependencies: [brand] }
  );

  const notice = copyright ?? `© ${new Date().getFullYear()} ${brand}`;

  return (
    <footer
      ref={rootRef}
      className={cn(
        'relative isolate w-full overflow-hidden border-t border-[#9fd4d6]/12 bg-[#03110f] pt-16 pb-8 text-[#e8f3f2] max-sm:pt-12',
        className
      )}
      {...props}
    >
      <div
        aria-hidden="true"
        data-footer-glow=""
        className="pointer-events-none absolute inset-x-0 bottom-0 -z-10 h-[420px] origin-bottom max-sm:h-[300px]"
      >
        <span className="absolute top-full left-1/2 h-[200%] w-[150%] -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-[radial-gradient(closest-side,rgb(4_95_100/0.6)_0%,rgb(4_95_100/0.24)_45%,rgb(4_95_100/0)_100%)]" />
        <span className="absolute top-full left-1/2 h-[110%] w-[70%] -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-[radial-gradient(closest-side,rgb(198_245_111/0.16)_0%,rgb(198_245_111/0.05)_55%,rgb(198_245_111/0)_100%)] mix-blend-plus-lighter" />
      </div>

      <div className="mx-auto grid w-full max-w-[1320px] gap-4 px-5 max-sm:gap-10 sm:px-8">
        <div
          data-footer-rise=""
          className="flex flex-wrap items-start justify-between gap-12 max-md:flex-col max-md:gap-10"
        >
          <div className="grid w-[300px] justify-items-start gap-6 max-md:w-full">
            <a href="/" className={cn('flex items-center gap-2.5', focusRing)}>
              {logo}
              <span className="text-lg font-semibold tracking-tight">{brand}</span>
            </a>
            <p className="text-[15px] leading-relaxed text-[#9fd4d6]/70">{description}</p>
            {socials.length > 0 ? (
              <ul aria-label="Social links" className="flex gap-3">
                {socials.map((social) => (
                  <li key={social.href}>
                    <a
                      href={social.href}
                      target="_blank"
                      rel="noopener noreferrer"
                      aria-label={`${brand} on ${social.label}`}
                      className={cn(
                        'group/social relative flex size-11 items-center justify-center rounded-full opacity-70',
                        'bg-[linear-gradient(175deg,rgb(159_212_214/0.36)_0%,rgb(159_212_214/0)_19%,rgb(159_212_214/1)_41%,rgb(159_212_214/0)_77%,rgb(159_212_214/0.05)_100%)]',
                        "before:absolute before:inset-px before:rounded-full before:bg-[#03110f] before:transition-colors before:duration-300 before:content-['']",
                        'transition-[opacity,translate] duration-500 hover:-translate-y-0.5 hover:opacity-100 hover:before:bg-[#071d1b]',
                        EASE_OUT,
                        'focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f]',
                        'motion-reduce:transition-none motion-reduce:hover:translate-y-0'
                      )}
                    >
                      <svg
                        aria-hidden="true"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="2"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        className="relative size-4 text-[#e8f3f2] transition-colors duration-300 group-hover/social:text-[#c6f56f] motion-reduce:transition-none"
                      >
                        {SOCIAL_PATHS[social.icon]}
                      </svg>
                    </a>
                  </li>
                ))}
              </ul>
            ) : null}
          </div>

          <nav aria-label="Footer" className="flex flex-wrap gap-x-20 gap-y-10 max-lg:gap-x-12">
            {columns.map((column) => (
              <LinkGroup key={column.heading} column={column} />
            ))}
          </nav>
        </div>

        <div ref={boxRef} className="min-w-0">
          <p
            ref={markRef}
            aria-hidden="true"
            className={cn(
              'pointer-events-none pb-2 text-center text-[length:min(20vw,240px)] leading-none font-bold tracking-[0.02em] whitespace-nowrap text-transparent uppercase select-none',
              '[--fade:linear-gradient(to_top,rgb(159_212_214/0)_13%,rgb(159_212_214/0.7)_100%)] max-sm:[--fade:linear-gradient(to_bottom,rgb(159_212_214/0.7)_13%,rgb(159_212_214/0)_88%)]',
              'bg-(image:--fade) bg-clip-text data-split:bg-none',
              '[&_.fwr-char]:inline-block [&_.fwr-char]:bg-(image:--fade) [&_.fwr-char]:bg-clip-text',
              '[&_.fwr-char-mask]:inline-block [&_.fwr-char-mask]:align-top'
            )}
          >
            <span ref={wordRef} className="-mr-[0.02em] inline-block">
              {brand}
            </span>
          </p>
        </div>

        <div
          data-footer-rise=""
          className="flex flex-wrap items-center justify-between gap-x-8 gap-y-3 border-t border-[#9fd4d6]/12 pt-6 max-sm:flex-col max-sm:items-start"
        >
          <p className="text-xs tracking-[0.14em] whitespace-nowrap text-[#9fd4d6]/65 uppercase">
            {notice}
          </p>
          <ul className="flex flex-wrap items-center gap-x-8 gap-y-3 max-sm:gap-x-6">
            {legal.map((link) => (
              <li key={`${link.label}-${link.href}`}>
                <a
                  href={link.href}
                  {...externalProps(link.href)}
                  className={cn(
                    'relative inline-block text-xs tracking-[0.14em] whitespace-nowrap text-[#9fd4d6]/65 uppercase transition-colors duration-400 hover:text-[#e8f3f2]',
                    EASE_OUT,
                    underline,
                    focusRing
                  )}
                >
                  {link.label}
                </a>
              </li>
            ))}
          </ul>
        </div>
      </div>
    </footer>
  );
}
```
