# Lintel — Animated Section Block for React

> Brand-agnostic landing hero in three layouts behind one shared header (logo, nav, sign-in + CTA, focus-trapped menu sheet under 640px): frame — a product window tilted back in perspective that flattens as the hero scrolls over a pooling accent glow; split — copy beside a CSS phone whose screen push-cycles three app screens; centered — a word-by-word blur-reveal headline, announcement pill and trust row over a drifting dot grid. Staggered intro, idle loops that pause on hover, focus and offscreen.

- Page: https://crucibleui.com/components/sections/lintel
- Category: [sections](https://crucibleui.com/components/sections)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/lintel
```

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

Import from `@/components/crucible/lintel`.

## Props (`LintelProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `layout?` | `LintelLayout` | `"frame"` | Composition: `frame` (copy over a product window that flattens on scroll), `split` (copy left, cycling phone right) or `centered` (big blur-reveal headline over a drifting dot grid with a trust row). |
| `eyebrow?` | `LintelEyebrow \| string \| false` | `{ tag: "New", label: "Scheduled workflows are here", href: "#" }` | Announcement pill above the headline. A string renders a plain pill; `false` hides it. |
| `title?` | `string` | `"The calm control room for fast-moving teams"` | Headline. |
| `subtitle?` | `React.ReactNode` | — | Supporting paragraph under the headline. |
| `primaryCta?` | `LintelAction \| false` | `{ label: "Start free trial", href: "#" }` | Solid accent call to action. `false` hides it. |
| `secondaryCta?` | `LintelAction \| false` | `{ label: "Book a demo", href: "#" }` | Quiet secondary call to action. `false` hides it. |
| `nav?` | `LintelNavLink[]` | `Product / Solutions / Pricing / Docs` | Header links (best with 4). Pass `[]` to hide. |
| `logo?` | `React.ReactNode` | `a neutral glyph + "Outpost" wordmark` | Header logo slot. |
| `logoHref?` | `string` | `"#"` | Where the logo links. |
| `signIn?` | `LintelAction \| false` | `{ label: "Sign in", href: "#" }` | Ghost sign-in action in the header. `false` hides it. |
| `headerCta?` | `LintelAction \| false` | ``primaryCta`'s target with the label "Get started"; `false` hides it` | Solid header CTA. |
| `frame?` | `React.ReactNode` | — | Custom visual. In `frame` it becomes the product window's interior (wrap regions in `ChassisPanel` for the boot treatment); in `split` it replaces the phone's cycling screens; in `centered` it renders under the trust row. |
| `trust?` | `string[]` | — | Company names for the trust row (`centered`). Each gets a neutral logo mark. `[]` hides the row. |
| `trustLabel?` | `string` | `"Trusted by teams at"` | Caption above the trust row. |
| `accentColor?` | `string` | `"#7dd3fc"` | Signature accent: CTA fill, focus rings, glow, bloom. Ink on accent fills auto-picks black/white. |
| `scroller?` | `React.RefObject<HTMLElement \| null>` | — | Scroll container that drives the `frame` tilt. Omit to follow the window. The container needs a non-static `position`. |
| `tilt?` | `number` | `18` | Start angle of the `frame` tilt, in degrees. |
| `cycleSeconds?` | `number` | `3.5` | Seconds each phone screen holds in `split` before pushing to the next. |
| `paused?` | `boolean` | `false` | Freeze ambient loops (phone cycling, grid drift, glow breathing, window idle life). |
| `seed?` | `number` | `11` | Seed for the sample data in the phone screens. |
| `ref?` | `React.Ref<LintelHandle>` | — | Imperative handle (`replay()`). React 19 ref-as-prop. |
| `className?` | `string` | — | Extra classes for the `<section>` root. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"section">, "title" | "children">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { Lintel, type LintelHandle, type LintelLayout } from "@/components/crucible/lintel";

const LAYOUTS: LintelLayout[] = ["frame", "split", "centered"];

export default function LintelDemo() {
  const ref = React.useRef<LintelHandle>(null);
  const scrollerRef = React.useRef<HTMLDivElement>(null);
  const [layout, setLayout] = React.useState<LintelLayout>("frame");

  const pick = (next: LintelLayout) => {
    setLayout(next);
    scrollerRef.current?.scrollTo({ top: 0 });
  };

  const onKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = LAYOUTS[(LAYOUTS.indexOf(layout) + dir + LAYOUTS.length) % LAYOUTS.length];
    pick(next);
    e.currentTarget.querySelector<HTMLButtonElement>(`[data-layout="${next}"]`)?.focus();
  };

  return (
    <div className="relative isolate flex h-[640px] w-full flex-col overflow-hidden rounded-xl bg-neutral-950">
      <div className="relative z-20 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-white/[0.06] bg-neutral-950 px-3 py-2.5">
        <div
          role="radiogroup"
          aria-label="Hero layout"
          onKeyDown={onKey}
          className="flex rounded-lg border border-white/10 bg-white/[0.03] p-0.5"
        >
          {LAYOUTS.map((l) => (
            <button
              key={l}
              type="button"
              role="radio"
              data-layout={l}
              aria-checked={layout === l}
              tabIndex={layout === l ? 0 : -1}
              onClick={() => pick(l)}
              className={cn(
                "rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors outline-none focus-visible:ring-2 focus-visible:ring-sky-300",
                layout === l ? "bg-white/10 text-white" : "text-white/50 hover:text-white/80"
              )}
            >
              {l}
            </button>
          ))}
        </div>
        <p className="text-xs text-white/40">Switch layouts — scroll the frame flat.</p>
        <button
          type="button"
          onClick={() => ref.current?.replay()}
          className="rounded text-xs text-white/40 underline underline-offset-4 outline-none transition-colors hover:text-white focus-visible:ring-2 focus-visible:ring-sky-300"
        >
          Replay intro
        </button>
      </div>

      <div ref={scrollerRef} className="relative isolate min-h-0 flex-1 overflow-y-auto overscroll-contain">
        <Lintel ref={ref} layout={layout} scroller={scrollerRef} />
        <div aria-hidden className="relative z-10 mx-auto grid max-w-5xl gap-3 px-6 pb-16 pt-4 sm:grid-cols-3">
          {[0, 1, 2].map((i) => (
            <div key={i} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-5">
              <div className="h-2.5 w-1/2 rounded-full bg-white/10" />
              <div className="mt-4 h-2 w-full rounded-full bg-white/[0.06]" />
              <div className="mt-2 h-2 w-4/5 rounded-full bg-white/[0.06]" />
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.
