@pagix/pdp-product-hero
v0.1.0
Published
Anvilkit Puck-native PDP full-screen hero — title + XMAGE badge over a configurable video/image/color background, with a centered price and a configurable list of call-to-action buttons. Ideal for the top of product detail pages.
Readme
@pagix/pdp-product-hero
Puck-native PDP full-screen hero — a full-bleed background, a centred
title with an optional badge, a price, and a configurable list of CTA
buttons beneath it. Replicates the section-kv / .kv-container module at
the top of a Huawei product detail page, with two axes the original does not
have: a three-way background and an arbitrary number of buttons.
Usage
import { createPdpProductHeroConfig } from "@pagix/pdp-product-hero";
const config = createPdpProductHeroConfig({ locale: "zh" });| Export | Purpose |
| --- | --- |
| componentConfig | The Puck ComponentConfig (English default) |
| pdpProductHeroConfig | Convenience alias of componentConfig |
| createPdpProductHeroConfig(options?) | Locale-aware factory ({ locale?, messages? }) |
| createComponentConfig(options?) | Alias of the factory |
| defaultProps / fields / metadata | Package contract |
| PdpProductHero | The RSC-safe component |
| colorField / imageUrlField / videoUrlField | Package-private Puck field factories |
Layout
Four regions, in this DOM order:
| Region | Position | Style target |
| --- | --- | --- |
| Background frame | In flow; 150vw / 50vw / 49vw tall (mobile / tablet / desktop) | background |
| Title + badge | Absolute, over the frame, offset from the section's top | title |
| Content column | In flow, after the frame: price then CTAs | content → price · buttons |
| Disclaimer | Absolute, pinned to the container's bottom corner | disclaimer |
The frame is a real in-flow element rather than an absolute layer, which is what the original does — the content column follows it, so the CTAs land below the fold line rather than floating over the video.
The title and disclaimer are position: absolute, so they sit over the
background while staying in the section's reading order. On mobile the
disclaimer goes full-width and centred; from md up it moves to the
bottom-right corner and switches to a light grey so it keeps contrast
against the bright edges of the video.
Background: three mutually exclusive payloads
backgroundType picks exactly one:
| Value | Renders | Payload prop |
| --- | --- | --- |
| video | <video autoPlay muted loop playsInline> + a responsive <picture> poster layer | backgroundVideo |
| image | <picture> with per-band <source> | backgroundImage |
| color | A flat aria-hidden block | backgroundColor |
All three groups stay declared in fields; resolveFields only changes
which one the inspector shows, so switching type and back keeps whatever
was authored under the others. Documents saved before backgroundType
existed fall back to the video group — the original site's payload.
An empty payload in any of the three renders a placeholder block instead of
nothing, so a newly dropped node stays selectable and keeps its
background style target.
Art direction
backgroundImage and backgroundVideo carry one source per band:
| Band | Breakpoint |
| --- | --- |
| pc | ≥ 1024px |
| pad | 768–1023px |
| mobile | < 768px |
An empty narrower band falls back to the desktop source at render time, so
authoring one file is enough. The <source> list is ordered widest
first: a browser that ignores media on <video><source> (support is
uneven) then lands on the desktop file — the sane fallback — rather than a
phone crop stretched across a monitor.
Layout and asset selection use the same width breakpoints on purpose.
The original converts its aspect-ratio media queries (max-aspect-ratio:
6/5, 7/10) into width queries; a width-selected asset set feeding an
aspect-ratio-sized frame is exactly the mismatch that can leave a band with
no matching art.
backgroundFit (cover / contain) decides whether the payload fills or
letterboxes inside the frame. It is ignored by the colour block, which has
no intrinsic aspect to fit.
Buttons
buttons is an array field — add, remove and reorder at will. Each item:
| Key | Type | Notes |
| --- | --- | --- |
| text | string | The label |
| link | string | Empty renders an inert control rather than a dead link |
| variant | "primary" \| "secondary" \| "ghost" | primary is the original's filled black pill |
| openInNewPage | boolean | Adds target="_blank" + rel="noopener noreferrer" |
secondary and ghost draw their colour from titleColor, border
included, so an inverted hero stays legible without a second colour prop.
An empty list renders the CTA row with nothing in it — the buttons style
target stays present, so the group can still be selected and styled.
Props
| Prop | Type | Notes |
| --- | --- | --- |
| title | string | Centred <h1>; 8.33vw / 4.9vw (mobile / desktop) |
| badgeText | string? | Short chip after the title, e.g. XMAGE; empty renders no chip |
| badgeImageUrl | string? | Badge artwork; wins over badgeText when set |
| titleColor | string | Required; "" keeps the built-in black. Also colours the outline buttons |
| price | number? | Rendered as <currency> <price>; omitted from the DOM when undefined |
| currency | string? | Default ¥ |
| priceSuffix | string? | Trailing qualifier, e.g. 起 |
| priceColor | string | Required; "" keeps the built-in black |
| disclaimerText | string? | Conditional on its prop — no element when empty |
| backgroundType | "video" \| "image" \| "color" | Which payload fills the frame |
| backgroundImage | { pc, pad, mobile, alt } | alt empty marks the image decorative |
| backgroundVideo | { pcVideoUrl, pcPosterUrl, padVideoUrl, padPosterUrl, mobileVideoUrl, mobilePosterUrl } | |
| backgroundColor | string | Flat fill for backgroundType: "color"; "" shows the placeholder |
| backgroundFit | "cover" \| "contain" | Default cover |
| buttons | PdpProductHeroButton[] | See above |
| classNames | Record<string, string> | Tailwind passthrough, one key per style target |
| animation | AnimationProps | Entrance animation, applied to the root |
Why the title is text, not the original's SVG wordmark
The original renders the product name as an inline <svg> wordmark. Here it
is a real <h1> with authorable text, which is what a CMS-driven PDP needs.
The badge case is covered by badgeImageUrl — an uploaded SVG renders inside
an <img>, so any script it carries cannot execute. That is the reason this
package has no dangerouslySetInnerHTML path for author markup.
Video upload caveat
pcVideoUrl accepts a pasted URL or a file upload. An upload becomes a
session-scoped URL.createObjectURL — it previews immediately but is gone
after a reload, since video bytes are far too large to inline as a data URL
the way images do. Paste a hosted URL for anything that must persist. Same
trade-off as @pagix/video-banner and @pagix/main-product-hero.
The poster/band URLs are plain text fields for the same reason — one upload slot per video, URLs for everything else.
Style targets
root · background · title · content · price · buttons · disclaimer
All seven are stamped in every render mode, including edit mode, so a target can never be declared without a matching element.
Styling
styles.css imports @anvilkit/tailwind-config/component and scans the
@anvilkit/ui Button source (the CTA pills render through it). The
@source path is five levels up — packages/runtime/ui/src/button.tsx.
License
MIT
