Skip to content

Collection 01 / Interactive

Orbit Hero.

A bold headline and pointer-responsive orbital artwork. Built for the first impression.

Live playground

Accent

Independent designer & developer

Ideas into something real.

Thoughtful digital experiences. A little unexpected. Always made for people.

import { OrbitHero } from "@/components/gear5/orbit-hero";

export default function Example() {
  return <OrbitHero accent={"#d9fc87"} />;
}

Make it yours.

npx shadcn@latest add https://gear5-ui.vercel.app/r/orbit-hero.json

Copies editable source into your project. Setup guide · React and Tailwind CSS 4 required.

Put it to work.

import { OrbitHero } from "@/components/gear5/orbit-hero";
export default function Hero() {
  return <OrbitHero headingLevel="h1" title={"Ideas into\nsomething real."}
    accent="#d9fc87"
    action={{ label: "See my work", href: "#work" }}
    secondaryAction={{ label: "Get in touch", href: "mailto:you@example.com" }} />;
}

Good to know.

  • Set headingLevel to h1 for your page hero; the default h2 suits embedded sections.
  • Pointer tilt is decorative and disabled by the user’s reduced-motion preference. Touch users get the same content and actions.
  • Change artworkLabels, mark, accent, action, and secondaryAction to match your site.

Source & props.

Installer-ready imports. For manual installation, copy this file into components/gear5/orbit-hero.tsx and add the helpers below at their shown paths.

"use client";

import { useRef, type CSSProperties } from "react";
import { sanitizeHref } from "@/lib/gear5/sanitize";

export interface OrbitHeroProps {
  eyebrow?: string;
  title?: string;
  description?: string;
  action?: { label: string; href: string };
  secondaryAction?: { label: string; href: string };
  accent?: string;
  headingLevel?: "h1" | "h2";
  artworkLabels?: [string, string, string];
  mark?: string;
  className?: string;
}

/** Pointer-responsive artwork is decorative; content and links work without motion. */
export function OrbitHero({
  eyebrow = "Independent designer & developer",
  title = "Ideas into\nsomething real.",
  description = "Thoughtful digital experiences. A little unexpected. Always made for people.",
  action = { label: "Explore my work", href: "#work" },
  secondaryAction = { label: "Let’s talk", href: "#contact" },
  accent = "#d9fc87",
  headingLevel: Heading = "h2",
  artworkLabels = ["Design", "Build", "Make it move"],
  mark = "G5",
  className = "",
}: OrbitHeroProps) {
  const art = useRef<HTMLDivElement>(null);
  return (
    <section
      style={{ "--g5-accent": accent } as CSSProperties}
      className={`overflow-hidden rounded-3xl bg-[#111214] p-6 text-[#f4f4ef] sm:p-10 ${className}`}
    >
      <div className="grid items-center gap-8 lg:grid-cols-[1.1fr_1fr]">
        <div>
          <p className="mb-6 flex items-center gap-2 font-mono text-[11px] tracking-[.12em] text-[#c2c4bd] uppercase">
            <span
              aria-hidden="true"
              className="size-1.5 rounded-full bg-[var(--g5-accent)]"
            />
            {eyebrow}
          </p>
          <Heading className="whitespace-pre-line text-[clamp(2.6rem,6.5vw,5.7rem)] leading-[.99] font-medium tracking-[-.075em]">
            {title}
          </Heading>
          <p className="mt-6 max-w-md text-sm leading-7 text-[#b4b6af] sm:text-base">
            {description}
          </p>
          <div className="mt-8 flex flex-wrap gap-3">
            <a
              href={sanitizeHref(action.href)}
              className="inline-flex min-h-11 items-center gap-5 rounded-full bg-[var(--g5-accent)] px-6 py-3 text-sm font-semibold text-[#111214] transition-opacity hover:opacity-85 motion-reduce:transition-none"
            >
              {action.label}
              <span aria-hidden="true">↗</span>
            </a>
            <a
              href={sanitizeHref(secondaryAction.href)}
              className="inline-flex min-h-11 items-center rounded-full border border-white/20 px-5 py-3 text-sm transition-colors hover:bg-white/10 motion-reduce:transition-none"
            >
              {secondaryAction.label}
            </a>
          </div>
        </div>
        <div
          ref={art}
          aria-hidden="true"
          className="relative mx-auto flex aspect-square w-full max-w-[460px] items-center justify-center [perspective:900px]"
          onPointerMove={(event) => {
            if (event.pointerType === "touch" || !art.current) return;
            const box = event.currentTarget.getBoundingClientRect();
            art.current.style.setProperty(
              "--g5-x",
              `${((event.clientY - box.top) / box.height - 0.5) * -14}deg`,
            );
            art.current.style.setProperty(
              "--g5-y",
              `${((event.clientX - box.left) / box.width - 0.5) * 14}deg`,
            );
          }}
          onPointerLeave={() => {
            art.current?.style.setProperty("--g5-x", "0deg");
            art.current?.style.setProperty("--g5-y", "0deg");
          }}
        >
          <div className="absolute inset-6 rounded-full border border-dashed border-white/15" />
          <div className="absolute inset-16 rounded-full border border-white/10" />
          <div className="relative flex size-[65%] items-center justify-center rounded-[2.5rem] border border-white/15 bg-[#1e2023] shadow-[0_30px_80px_#0008] transition-transform duration-500 motion-safe:[transform:rotateX(var(--g5-x,0deg))_rotateY(var(--g5-y,0deg))_rotate(-12deg)] motion-reduce:transition-none">
            <span className="font-mono text-[clamp(4rem,10vw,8rem)] font-bold tracking-[-.12em] text-[var(--g5-accent)]">
              {mark}
            </span>
            <span className="absolute inset-5 rounded-[1.7rem] border border-white/10" />
          </div>
          <span className="absolute start-0 top-[17%] rounded-xl border border-white/20 bg-[#282b2e] px-5 py-3 font-mono text-xs shadow-xl motion-safe:-rotate-12">
            {artworkLabels[0]}
            <span className="ms-4 text-[var(--g5-accent)]">✳</span>
          </span>
          <span className="absolute end-0 top-[40%] rounded-xl bg-[var(--g5-accent)] px-5 py-3 font-mono text-xs text-[#111214] shadow-xl motion-safe:rotate-6">
            {artworkLabels[1]}
            <span className="ms-4">↗</span>
          </span>
          <span className="absolute bottom-[12%] start-[12%] rounded-xl border border-white/20 bg-[#282b2e] px-5 py-3 font-mono text-xs shadow-xl motion-safe:rotate-6">
            {artworkLabels[2]}
            <span className="ms-4 text-[var(--g5-accent)]">⌘</span>
          </span>
        </div>
      </div>
    </section>
  );
}

Helper: sanitize → lib/gear5/sanitize.ts
/**
 * Security primitive: neutralise the one class of URL that turns "render a
 * link" into "run arbitrary script" — `javascript:` and other executable
 * schemes reaching an `href`/`src` from data the app did not author itself
 * (a CMS field, a query param, a user's own profile link).
 */

const SAFE_SCHEMES = new Set(["http:", "https:", "mailto:", "tel:", "sms:"]);

/**
 * Returns `url` unchanged if its scheme is safe to navigate to, or `"#"`
 * otherwise. Relative URLs (no scheme) are always safe and pass through.
 */
export function sanitizeHref(url: string): string {
  const trimmed = url.trim();
  if (!trimmed) return "#";

  // Browsers discard tabs and newlines when parsing a URL scheme.
  const schemeProbe = trimmed.replace(/[\t\r\n]/g, "");
  if (!/^[a-z][a-z0-9+.-]*:/i.test(schemeProbe)) return trimmed;

  try {
    const scheme = new URL(trimmed, "https://example.com").protocol;
    return SAFE_SCHEMES.has(scheme) ? trimmed : "#";
  } catch {
    return "#";
  }
}

VERIFIED: AXE FIXTURES / SERVER RENDER / SOURCE SCANS / 3300 B GZIP CEILING
Test your own content and screen-reader flows before shipping.