DGit UI › Blocks

Marketing

For landing and product pages only: glass nav, hero, gradient stage with window frame, feature tiles, review wall, FAQ, article cards, dot band, footer, reveal. App screens stay monochrome.

Install

npx shadcn add @dgit/marketing

First time? Add the registry to components.json: see Get started.

Depends on @dgit/button, @dgit/theme, lucide-react.

marketing.tsx

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/dgit/ui/button";

/** Rises in once when it scrolls into view. `index` staggers siblings by 70 ms. */
export function Reveal({ children, index = 0, className, as: Tag = "div" }: { children: ReactNode; index?: number; className?: string; as?: "div" | "li" | "section" }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = ref.current; if (!el) return;
    const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { el.classList.add("mk-in"); io.disconnect(); } }, { rootMargin: "0px 0px -8% 0px" });
    io.observe(el); return () => io.disconnect();
  }, []);
  const T = Tag as "div";
  return <T ref={ref} className={cn("mk-reveal", className)} style={{ "--i": index } as CSSProperties}>{children}</T>;
}

/** Centered first screen: optional mark, big balanced title, one muted line, one pill CTA. */
export function Hero({ mark, title, sub, cta, children }: { mark?: ReactNode; title: string; sub: string; cta: string; children?: ReactNode }) {
  return (
    <section className="px-4 pt-12 text-center sm:pt-20">
      {mark && <Reveal index={0} className="mx-auto mb-8 flex justify-center">{mark}</Reveal>}
      <Reveal index={1}><h1 className="mk-display mx-auto max-w-[16ch] sm:max-w-3xl">{title}</h1></Reveal>
      <Reveal index={2}><p className="mx-auto mt-5 max-w-[34ch] text-copy-15 font-medium text-muted-foreground sm:max-w-md sm:text-[17px] sm:leading-7">{sub}</p></Reveal>
      <Reveal index={3} className="mt-9"><Button size="lg" className="active:scale-[0.97] px-7 text-base transition-[opacity,transform] duration-300">{cta}</Button></Reveal>
      {children}
    </section>
  );
}

/** Stage: a soft grainy surface; put a WindowFrame on it to show a product shot big. */
export function Stage({ children, className }: { children: ReactNode; className?: string }) {
  return <div className={cn("mk-stage rounded-none px-4 py-14 sm:px-[8%] sm:py-20", className)}>{children}</div>;
}
export function WindowFrame({ title, children }: { title: string; children: ReactNode }) {
  return (
    <div className="mx-auto w-full max-w-5xl overflow-hidden rounded-xl shadow-card mk-frame">
      <div className="flex h-8 items-center gap-1.5 px-3">
        <i className="size-2.5 rounded-full bg-[#ff5f57]" /><i className="size-2.5 rounded-full bg-[#febc2e]" /><i className="size-2.5 rounded-full bg-[#28c840]" />
        <span className="ml-3 text-label-12 text-muted-foreground">{title}</span>
      </div>
      <div className="bg-background-100">{children}</div>
    </div>
  );
}

/** Section title, big, with an optional "See all" link on the right. */
export function SectionHead({ title, action, center }: { title: string; action?: string; center?: boolean }) {
  return (
    <div className={cn("mb-10 flex items-end justify-between gap-6 sm:mb-14", center && "justify-center text-center")}>
      <Reveal><h2 className="mk-h1">{title}</h2></Reveal>
      {action && <a href="#top" className="active:scale-[0.97] text-copy-15 text-muted-foreground transition-colors hover:text-foreground">{action}</a>}
    </div>
  );
}

/** Feature tile: a big rounded gray surface holding a demo, then a title and one line. The surface darkens on hover. */
export function FeatureTile({ visual, title, body, index = 0 }: { visual: ReactNode; title: string; body: string; index?: number }) {
  return (
    <Reveal index={index} className="group">
      <div className="aspect-[4/3] overflow-hidden rounded-3xl bg-gray-100 transition-colors duration-[400ms] group-hover:bg-gray-200">{visual}</div>
      <h3 className="mt-5 text-center text-heading-20">{title}</h3>
      <p className="mt-1.5 text-center text-copy-15 text-muted-foreground">{body}</p>
    </Reveal>
  );
}

/** Review wall: soft cards (avatar, name, role, big quote) stacked in columns of uneven height. */
export type Review = { name: string; role: string; text: string };
export function ReviewWall({ items }: { items: Review[] }) {
  return (
    <div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
      {items.map((r, i) => (
        <Reveal key={r.name} index={i % 3} className="mb-4 break-inside-avoid">
          <figure className="rounded-3xl bg-gray-100 p-7">
            <figcaption className="flex items-center gap-3.5">
              <span aria-hidden className="flex size-14 items-center justify-center rounded-full bg-gray-300 text-heading-20">{r.name[0]}</span>
              <span><span className="block text-heading-16">{r.name}</span><span className="block text-copy-15 text-muted-foreground">{r.role}</span></span>
            </figcaption>
            <blockquote className="mt-6 text-[17px] leading-7">{r.text}</blockquote>
          </figure>
        </Reveal>
      ))}
    </div>
  );
}

/** FAQ: hairlines between rows, the + turns 45° into ×, the answer opens by height (grid-rows 0fr to 1fr). */
export function Faq({ items }: { items: { q: string; a: string }[] }) {
  const [open, setOpen] = useState<number | null>(0);
  return (
    <div className="divide-y divide-border border-y">
      {items.map((it, i) => (
        <div key={it.q}>
          <button type="button" aria-expanded={open === i} onClick={() => setOpen(open === i ? null : i)} className="flex w-full items-center justify-between gap-6 py-6 text-left text-heading-20">
            {it.q}<Plus className={cn("size-5 shrink-0 transition-transform duration-300", open === i && "rotate-45")} />
          </button>
          <div className={cn("grid transition-[grid-template-rows] duration-300", open === i ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
            <p className="min-h-0 overflow-hidden pr-10 text-copy-15 text-muted-foreground"><span className="block pb-6">{it.a}</span></p>
          </div>
        </div>
      ))}
    </div>
  );
}

/** Article card: a near-square stage surface with big text, then a title and a meta line. */
export function ArticleCard({ title, meta, cover, index = 0 }: { title: string; meta: string; cover: ReactNode; index?: number }) {
  return (
    <Reveal index={index}>
      <a href="#top" className="group block">
        <div className="mk-stage grid aspect-[5/4] place-items-center rounded-3xl text-center text-heading-30 text-foreground transition-transform duration-[400ms] group-hover:scale-[0.99]">{cover}</div>
        <h3 className="mt-4 text-heading-20">{title}</h3>
        <p className="mt-1 text-copy-14 text-muted-foreground">{meta}</p>
      </a>
    </Reveal>
  );
}

/** Closing band: a big sentence and a CTA on a dot grid. */
export function DotBand({ title, cta }: { title: string; cta: string }) {
  return (
    <div className="mk-dots relative overflow-hidden rounded-[32px] bg-gray-100 px-6 py-20 text-center sm:py-28">
      <h2 className="mk-h1 mx-auto max-w-[18ch]">{title}</h2>
      <Button size="lg" className="active:scale-[0.97] mt-8 px-7 text-base">{cta}</Button>
    </div>
  );
}

/** Footer: link columns (bold title, muted links) and a huge faint wordmark cropped at the bottom. */
export function Footer({ cols, word }: { cols: { title: string; links: string[] }[]; word: string }) {
  return (
    <footer className="overflow-hidden px-4 pt-14 sm:px-8">
      <div className="mx-auto grid max-w-6xl grid-cols-2 gap-8 sm:grid-cols-3 lg:grid-cols-5">
        {cols.map((c) => (
          <div key={c.title}>
            <p className="text-heading-16">{c.title}</p>
            <ul className="mt-4 space-y-2.5">{c.links.map((l) => <li key={l}><a href="#top" className="text-copy-15 text-muted-foreground transition-colors hover:text-foreground">{l}</a></li>)}</ul>
          </div>
        ))}
      </div>
      <p aria-hidden className="mt-16 -mb-[0.28em] select-none text-center font-semibold leading-none tracking-[-0.05em] text-gray-200" style={{ fontSize: "min(24vw, 340px)" }}>{word}</p>
    </footer>
  );
}

marketing-nav.tsx

import { useState } from "react";
import { Menu, X, ChevronDown } from "lucide-react";
import { Button } from "@/registry/dgit/ui/button";
import { cn } from "@/lib/utils";

/** Glass bar on top: links in the middle, a pill CTA on the right, a hamburger on phones. The current link is dark, the rest muted. */
export function NavBar({ brand, links, cta }: { brand: string; links: { label: string; menu?: string[] }[]; cta: string }) {
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(links[0]?.label ?? "");
  const [drop, setDrop] = useState<string | null>(null);
  return (
    <header className="mk-nav sticky top-0 z-30">
      <div className="mx-auto flex h-14 max-w-6xl items-center px-4 sm:px-8">
        <a href="#top" className="text-heading-20 tracking-tight">{brand}</a>
        <nav aria-label="Main" className="absolute left-1/2 hidden -translate-x-1/2 items-center gap-7 md:flex">
          {links.map((l) => (
            <div key={l.label} className="relative" onMouseLeave={() => setDrop(null)} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDrop(null); }} onKeyDown={(e) => { if (e.key === "Escape") setDrop(null); }}>
              <button type="button" onMouseEnter={() => l.menu && setDrop(l.label)} onClick={() => (l.menu ? setDrop(drop === l.label ? null : l.label) : setActive(l.label))} aria-haspopup={l.menu ? "menu" : undefined} aria-expanded={l.menu ? drop === l.label : undefined}
                className={cn("active:scale-[0.97] inline-flex items-center gap-1 text-label-14 font-medium transition-colors", active === l.label ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
                {l.label}{l.menu && <ChevronDown className="size-3.5" />}
              </button>
              {l.menu && drop === l.label && (
                <div className="animate-menu absolute left-1/2 top-full w-60 -translate-x-1/2 pt-3">
                  <ul className="shadow-menu rounded-2xl bg-popover p-1.5">
                    {l.menu.map((m) => <li key={m}><a href="#top" className="block rounded-lg px-3 py-2 text-copy-14 transition-colors hover:bg-gray-100">{m}</a></li>)}
                  </ul>
                </div>
              )}
            </div>
          ))}
        </nav>
        <Button size="sm" className="ml-auto hidden md:inline-flex">{cta}</Button>
        <button type="button" aria-label={open ? "Close menu" : "Open menu"} aria-expanded={open} onClick={() => setOpen(!open)} className="active:scale-[0.97] -mr-2 ml-auto flex size-11 items-center justify-center md:hidden">
          {open ? <X className="size-5" /> : <Menu className="size-5" />}
        </button>
      </div>
      <div className={cn("grid overflow-hidden transition-[grid-template-rows] duration-300 md:hidden", open ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
        <div className="min-h-0" inert={!open}><div className="bg-background px-4 pb-4">
          {links.map((l, i) => <a key={l.label} href="#top" aria-current={active === l.label ? "page" : undefined} onClick={() => setActive(l.label)} style={{ transitionDelay: open ? `${i * 40}ms` : "0ms" }} className={cn("block py-3 text-heading-20 transition-all duration-300", open ? "translate-y-0 opacity-100" : "-translate-y-1 opacity-0")}>{l.label}</a>)}
          <Button className="mt-2 w-full">{cta}</Button>
        </div></div>
      </div>
    </header>
  );
}

marketing.css

/*
 * DGit UI marketing layer. Import after dgit.css, for landing and product pages ONLY; app screens stay monochrome.
 * Everything here is prefixed (--mk-*, .mk-*) so nothing leaks into the rest of the app.
 * Set <html data-marketing="flat"> to turn off the stage gradient, glass, blur and shadow (gray surfaces only).
 */
:root {
  --mk-dur-hover: 150ms;
  --mk-dur-cascade: 480ms;
  --mk-stagger: 70ms;
  --mk-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --mk-stage-a: #d9d9de;
  --mk-stage-b: #f1f1f3;
  --mk-stage-c: #9a9aa2;
  --mk-glass: hsla(var(--dgit-background-200), 0.8);
  --mk-glass-modal: #dedee0cc;
  --mk-dot: hsla(var(--dgit-gray-1000), 0.18);
}
:root[data-theme="dark"] { --mk-stage-a: #1c1c20; --mk-stage-b: #2a2a30; --mk-stage-c: #0c0c0e; --mk-glass-modal: #26262acc; --mk-dot: hsla(var(--dgit-gray-1000), 0.22); }
@media (prefers-color-scheme: dark) { :root:not([data-theme]) { --mk-stage-a: #1c1c20; --mk-stage-b: #2a2a30; --mk-stage-c: #0c0c0e; --mk-glass-modal: #26262acc; --mk-dot: hsla(var(--dgit-gray-1000), 0.22); } }

/* Big type steps, used only by marketing blocks. */
.mk-display, .mk-h1, .mk-h2 { text-wrap: balance; word-break: keep-all; }
.mk-display { font-size: 60px; line-height: 72px; font-weight: 700; letter-spacing: -1.8px; }
.mk-h1 { font-size: 50px; line-height: 62px; font-weight: 650; letter-spacing: -1.5px; }
.mk-h2 { font-size: 40px; line-height: 50px; font-weight: 650; letter-spacing: -1.2px; }
@media (max-width: 640px) {
  .mk-display, .mk-h1 { font-size: 30px; line-height: 39px; letter-spacing: -0.9px; }
  .mk-h2 { font-size: 26px; line-height: 34px; letter-spacing: -0.8px; }
}

/* Rise-in; siblings stagger by --i * 70ms. */
.mk-reveal { opacity: 0; transform: translateY(14px); }
.mk-reveal.mk-in { animation: mk-cascade-in var(--mk-dur-cascade) var(--mk-ease) both; animation-delay: calc(var(--i, 0) * var(--mk-stagger)); }
@keyframes mk-cascade-in { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce), print { .mk-reveal { opacity: 1; transform: none; } .mk-reveal.mk-in { animation: none; } }

/* Stage: a soft grainy surface behind product shots. */
.mk-stage {
  position: relative; isolation: isolate; overflow: hidden;
  background:
    radial-gradient(120% 90% at 0% 0%, var(--mk-stage-b) 0%, transparent 55%),
    radial-gradient(90% 80% at 100% 100%, var(--mk-stage-c) 0%, transparent 60%),
    var(--mk-stage-a);
}
.mk-stage::after {
  content: ""; position: absolute; inset: 0; z-index: -1; opacity: 0.35; mix-blend-mode: multiply; pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.mk-dots { background-image: radial-gradient(var(--mk-dot) 1px, transparent 1.2px); background-size: 24px 24px; }
.mk-glass { background: var(--mk-glass-modal); backdrop-filter: blur(40px) saturate(1.4); -webkit-backdrop-filter: blur(40px) saturate(1.4); }
.mk-frame { background: hsla(var(--dgit-background-100), 0.7); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
.mk-nav { background: linear-gradient(var(--mk-glass) 80%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
.mk-shadow { box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 10px 24px -8px rgba(0,0,0,.10); }

/* Flat variant: same blocks, gray surfaces only. */
html[data-marketing="flat"] .mk-stage { background: hsla(var(--dgit-gray-100), 1); }
html[data-marketing="flat"] .mk-stage::after { display: none; }
html[data-marketing="flat"] .mk-shadow { box-shadow: 0 0 0 1px hsla(var(--dgit-gray-300), 1); }
html[data-marketing="flat"] .mk-glass,
html[data-marketing="flat"] .mk-frame { background: hsla(var(--dgit-background-100), 1); backdrop-filter: none; -webkit-backdrop-filter: none; }
html[data-marketing="flat"] .mk-nav { background: hsla(var(--dgit-background-200), 1); backdrop-filter: none; -webkit-backdrop-filter: none; }

Source on GitHub