Ghibli Robot Hero
Ghibli Robot Hero é um componente de layout para React e Tailwind CSS, escrito para a NedDev e teu para usares. Copia e cola no teu projeto.
NedDev
layout
O que é
Ghibli Robot Hero é um componente de layout para React e Tailwind CSS, escrito para a NedDev e teu para usares. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Quatro cópias grátis por semana. Sem cartão.
Não precisa de pacotes além do que o projeto já tem
Licença
Este componente foi escrito para este catálogo. É teu para usares em trabalho de cliente e em produtos que vendas, nos termos de uso.
Ghibli Robot Hero
"use client";
import {
useEffect,
type AnchorHTMLAttributes,
type CSSProperties,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
/* --- Fonts --- */
/**
* The two faces this hero is drawn in. Averia Serif Libre carries the
* headline; General Sans carries everything else.
*
* They load from a stylesheet link rather than a bundler import so the file
* stays self-contained — drop it in and it looks right, with no font wiring in
* the host app. If your app already ships these faces, pass `loadFonts={false}`
* and the component uses whatever you have.
*/
const FONT_HREFS = [
"https://fonts.googleapis.com/css2?family=Averia+Serif+Libre:wght@300;400;700&display=swap",
"https://api.fontshare.com/v2/css?f[]=general-sans@300,400,500,600&display=swap",
];
const SERIF = "'Averia Serif Libre', ui-serif, Georgia, Cambria, serif";
const SANS =
"'General Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif";
/** Adds each font stylesheet to the head once, however many heroes are mounted. */
function useWebFonts(enabled: boolean) {
useEffect(() => {
if (!enabled || typeof document === "undefined") return;
for (const href of FONT_HREFS) {
if (document.querySelector(`link[href="${href}"]`)) continue;
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
}
}, [enabled]);
}
/* --- Motion --- */
/**
* The copy rises into place on load. Behind a reduced-motion guard, so a reader
* who asks for stillness gets a still page.
*/
const HERO_STYLES = `
@keyframes ghibli-robot-hero-rise {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.ghibli-robot-hero-rise { animation: none !important; opacity: 1 !important; transform: none !important; }
}
`;
/**
* What the button does under the pointer.
*
* The sheen has to be driven from `:hover` rather than left looping under a
* fading mask: a loop is wherever it happens to be when the pointer arrives, so
* half the time you catch it parked at the far edge and the button just glows.
* Tied to hover it starts off the left edge every time and crosses once.
*
* The face also lifts, the way it does on cluely.com — a wash of white blended
* with `overlay`, faded in over the paint rather than added to it.
*/
const BUTTON_STYLES = `
@keyframes ghibli-robot-hero-sheen {
from { transform: translateX(-140%) skewX(-16deg); }
to { transform: translateX(320%) skewX(-16deg); }
}
.ghibli-robot-hero-sheen { transform: translateX(-140%) skewX(-16deg); }
.ghibli-robot-hero-btn:hover .ghibli-robot-hero-sheen {
animation: ghibli-robot-hero-sheen 0.85s cubic-bezier(0.32, 0, 0.24, 1);
}
.ghibli-robot-hero-btn::after {
content: "";
position: absolute;
inset: 1px;
z-index: 1;
border-radius: inherit;
opacity: 0;
mix-blend-mode: overlay;
background: radial-gradient(101.79% 101.79% at 65.61% 81.79%, rgba(255,255,255,0.6) 0%, rgba(255,255,255,0) 100%);
transition: opacity 0.3s ease-in-out;
}
.ghibli-robot-hero-btn:hover::after { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.ghibli-robot-hero-btn:hover .ghibli-robot-hero-sheen { animation: none; }
}
`;
/** One rising line. `step` orders it against its neighbours. */
function rise(step: number): CSSProperties {
return {
animation: `ghibli-robot-hero-rise 0.7s cubic-bezier(0.22, 1, 0.36, 1) ${step * 0.09}s both`,
};
}
/* --- Button --- */
/**
* The blue pill from cluely.com, rebuilt from its computed styles.
*
* Four layers make the glass:
*
* 1. Two stacked radial gradients — a wash of white low and right, over a blue
* that runs from sky to ink — blended with `overlay`, which is what stops it
* reading as flat paint.
* 2. A hairline that is bright at the top and gone by the bottom. It is drawn
* as a padded box masked down to its own edge, because a plain border cannot
* take a gradient.
* 3. Two inset shadows: cold light on the top-left lip, deep blue in the
* bottom-right well.
* 4. A narrow band of light that crosses the face once per hover, over a wash
* that lifts the whole face while the pointer is on it. See `BUTTON_STYLES`.
*/
export interface GhibliRobotHeroButtonProps
extends AnchorHTMLAttributes<HTMLAnchorElement> {
/** Icon drawn to the left of the label. */
icon?: ReactNode;
children?: ReactNode;
}
export function GhibliRobotHeroButton({
icon,
children,
className,
style,
...rest
}: GhibliRobotHeroButtonProps) {
return (
<a
{...rest}
className={cn(
"ghibli-robot-hero-btn relative inline-flex w-fit items-center gap-[6px] overflow-hidden rounded-[10px]",
"px-5 py-[10px] text-[16px] font-medium tracking-[-0.13px] text-white",
"transition-transform duration-150 ease-out active:scale-[0.97]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent",
className,
)}
style={{
fontFamily: SANS,
backgroundImage:
"radial-gradient(101.79% 101.79% at 65.61% 81.79%, rgba(255,255,255,0.6) 0%, rgba(255,255,255,0) 100%), radial-gradient(114.65% 114.65% at 9.73% 17.27%, #1e82e0 0%, #1c38ea 100%)",
backgroundBlendMode: "overlay, normal",
boxShadow:
"20px 20px 24px rgba(148,172,243,0.4), inset -3px -3px 4px rgba(191,229,251,0.4), inset 4px 4px 4px rgba(19,26,228,0.1)",
...style,
}}
>
<style>{BUTTON_STYLES}</style>
{/* The lit edge. Blurred by a hair so it reads as glass, not as a stroke. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 z-20 blur-[1px]"
>
<span
className="absolute -left-px -top-px z-20 h-full w-full"
style={{
opacity: 0.45,
padding: 3,
borderRadius: 10,
background:
"linear-gradient(176.87deg, rgba(255,255,255,0.5) 8.56%, rgba(255,255,255,0) 85.04%)",
WebkitMask:
"linear-gradient(#fff, #fff) content-box, linear-gradient(#fff, #fff)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#fff, #fff) content-box, linear-gradient(#fff, #fff)",
maskComposite: "exclude",
}}
/>
</span>
{/* The sweep. It waits off the left edge and crosses once per hover. */}
<span
aria-hidden
className="ghibli-robot-hero-sheen pointer-events-none absolute inset-y-0 left-0 z-10 w-1/3 blur-[5px]"
style={{
background:
"linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 100%)",
}}
/>
{icon ? <span className="relative z-30 -mb-px">{icon}</span> : null}
<span className="relative z-30">{children}</span>
</a>
);
}
/* --- Types --- */
export interface GhibliRobotHeroProps {
/**
* The background picture, and the fallback for browsers that skip `sources`.
* Give it the largest copy you have.
*
* A hero this tall crops a landscape hard: the section takes the full height
* of the screen, so the browser scales the picture until its short side fits
* and throws the rest away sideways. That means the file wants to be tall,
* not merely wide — 1500px down the short side before it starts to soften on
* a retina screen.
*/
image?: string;
/**
* Smaller copies of the same picture with their widths, so a phone is not
* made to fetch the desktop file. Passed straight to `srcset`.
*/
sources?: Array<{ src: string; width: number }>;
/**
* What width to tell the browser to shop for in `sources`.
*
* The obvious `100vw` is wrong here and gives a soft picture. It measures the
* width of the box, but a full-height cover crop is decided by the height:
* the browser scales until the short side fits and spills the rest sideways,
* so the file it needs is as wide as the screen is *tall*, times the aspect
* of the picture. The default asks for whichever of the two is larger, at
* about 16:9. Pass `"100vw"` to favour bytes over detail.
*/
sizes?: string;
/** Alt text for the picture. Leave empty when it is pure decoration. */
imageAlt?: string;
/**
* Which part of the picture stays in frame as the section is cropped, given
* as a CSS `object-position`.
*/
focus?: string;
/** Small line above the headline. */
eyebrow?: ReactNode;
/** The headline. Set in Averia Serif Libre. */
title: ReactNode;
/** One or two lines under the headline. */
subtitle?: ReactNode;
/** Text on the button. Leave unset to drop the button. */
ctaLabel?: string;
/** Where the button points. */
ctaHref?: string;
/** Icon inside the button, to the left of the label. */
ctaIcon?: ReactNode;
/** Quiet line under the button — a price, a platform, a promise. */
note?: ReactNode;
/**
* How hard the picture is darkened behind the copy, from 0 to 1. The default
* of 0.85 keeps the meadow bright and only weighs down the sky.
*/
scrim?: number;
/** How far down the section the copy sits, from 0 (top) to 1 (bottom). */
lead?: number;
/**
* Height of the section. Any CSS length. The default fills the screen, so
* the picture runs to the bottom edge with no strip of page showing under it.
*/
minHeight?: string;
/** Load Averia Serif Libre and General Sans from the web. */
loadFonts?: boolean;
/** Replaces the button and the note under it. */
children?: ReactNode;
className?: string;
}
/* --- Component --- */
/**
* A hero that sits inside its picture rather than on top of it.
*
* The problem with white type on a bright photograph is that a flat black
* overlay fixes the reading and ruins the picture. So the veil here is a
* gradient: heavy across the sky, where the words are, and gone by the middle
* of the frame, where the land begins. A soft pool of shade sits behind the
* copy itself for the same reason — it follows the text instead of the frame.
*
* The headline is a serif and everything around it is a grotesque, so the two
* never compete. Both faces load themselves; see `loadFonts`.
*/
export function GhibliRobotHero({
image = "/ghibli-robot-hero-5504.webp",
sources,
sizes = "max(100vw, 180svh)",
imageAlt = "",
focus = "50% 62%",
eyebrow,
title,
subtitle,
ctaLabel,
ctaHref = "#",
ctaIcon,
note,
scrim = 0.85,
lead = 0.34,
minHeight = "100svh",
loadFonts = true,
children,
className,
}: GhibliRobotHeroProps) {
useWebFonts(loadFonts);
const veil = Math.max(0, Math.min(1, scrim));
return (
<section
className={cn(
"relative isolate w-full overflow-hidden bg-[#0d2440]",
className,
)}
style={{ minHeight, fontFamily: SANS }}
>
<style>{HERO_STYLES}</style>
{/* Picture, then the two veils, then the copy — stacked by explicit z
rather than by a negative index on the picture, so a background set on
the section from outside cannot land in the middle of the pile. */}
<img
src={image}
srcSet={
sources?.length
? sources.map((s) => `${s.src} ${s.width}w`).join(", ")
: undefined
}
sizes={sources?.length ? sizes : undefined}
alt={imageAlt}
aria-hidden={imageAlt ? undefined : true}
decoding="async"
className="absolute inset-0 z-0 h-full w-full object-cover"
style={{ objectPosition: focus }}
/>
{/* At scrim 0 the picture is left alone and the text carries itself on
its own shadow, so neither veil is drawn at all. */}
{veil > 0 ? (
<>
{/* Weight across the reading band, and none at the top edge — a veil
that starts at full strength turns a blue sky grey. */}
<div
aria-hidden
className="absolute inset-0 z-0"
style={{
background: `linear-gradient(to bottom,
rgba(5,18,40,${0.14 * veil}) 0%,
rgba(5,18,40,${0.3 * veil}) 30%,
rgba(5,18,40,${0.12 * veil}) 58%,
rgba(5,18,40,0) 74%)`,
}}
/>
{/* Shade that follows the words rather than the frame. It is tall
enough to reach the line under the button, which otherwise lands
on cloud. */}
<div
aria-hidden
className="absolute inset-0 z-0"
style={{
background: `radial-gradient(56% 54% at 50% ${lead * 100 + 12}%,
rgba(3,14,32,${0.46 * veil}) 0%,
rgba(3,14,32,${0.26 * veil}) 48%,
rgba(3,14,32,0) 78%)`,
}}
/>
</>
) : null}
<div
className="relative z-10 flex h-full w-full flex-col items-center px-6 text-center sm:px-10"
style={{
minHeight,
paddingTop: `calc(${lead} * ${minHeight} - 6rem)`,
paddingBottom: "4rem",
}}
>
{eyebrow ? (
<p
className="ghibli-robot-hero-rise mb-5 text-[13px] font-medium uppercase tracking-[0.18em] text-white/70"
style={rise(0)}
>
{eyebrow}
</p>
) : null}
<h1
className="ghibli-robot-hero-rise max-w-[20ch] text-balance text-[clamp(2.5rem,7.4vw,5rem)] font-normal leading-[1.02] tracking-[-0.02em] text-white"
style={{
...rise(1),
fontFamily: SERIF,
textShadow: "0 2px 30px rgba(3,16,38,0.4)",
}}
>
{title}
</h1>
{subtitle ? (
<p
className="ghibli-robot-hero-rise mt-6 max-w-[86ch] text-pretty text-[clamp(1rem,1.5vw,1.175rem)] font-normal leading-relaxed text-white"
style={{
...rise(2),
textShadow: "0 1px 18px rgba(3,16,38,0.45)",
}}
>
{subtitle}
</p>
) : null}
<div
className="ghibli-robot-hero-rise mt-9 flex flex-col items-center gap-3"
style={rise(3)}
>
{children ??
(ctaLabel ? (
<GhibliRobotHeroButton href={ctaHref} icon={ctaIcon}>
{ctaLabel}
</GhibliRobotHeroButton>
) : null)}
{note ? (
<p
className="text-[13px] text-white/70"
style={{ textShadow: "0 1px 10px rgba(3,16,38,0.9)" }}
>
{note}
</p>
) : null}
</div>
</div>
</section>
);
}
export default GhibliRobotHero;