Any face. Same product. Your brand.

Choose model faces that fit your brand and keep the look consistent across your entire catalogue. Images resized for Shopify, Amazon, Instagram and other popular platforms.

  • Professional retouching
  • Quality checks by fashion stylists
  • Consistent light & shadows
  • Up to 4K resolution

TeamHero (above)

Full-viewport hero. Portraits auto-advance every 1.5 seconds; click an avatar to pick one. Headline and paragraph stay fixed (omit description to show each member's bio instead). When points are given they fill the footer row in place of name, role, tenure and link. Newlines in the headline become line breaks. Manrope for the headline, Inter for body, white Lucide icons on the points, primary token on the active dot and link underline. Portraits and avatars are local files under public/team-hero/.

<TeamHero
  headline={"Any face.\nSame product.\nYour brand."}
  description="Choose model faces that fit your brand and keep the look consistent across your entire catalogue. Images resized for Shopify, Amazon, Instagram and other popular platforms."
  points={[                      // { label, icon? }[]; icon = wand | shirt | sun | expand | sparkles | camera | image, or an <svg> string
    { icon: "wand", label: "Professional retouching" },
    { icon: "shirt", label: "Quality checks by fashion stylists" },
    { icon: "sun", label: "Consistent light & shadows" },
    { icon: "expand", label: "Up to 4K resolution" },
  ]}
  members={team}                 // { name, role, description?, image, avatar? }[]
  tenure="In the business since 2020"
  contactLabel="WhatsApp"
  contactHref="#"
  initial={0}
  interval={1500}                // ms between auto-advances, 0 = off
/>
PropTypeDefaultWhat it does
headlinestringrequiredFixed H1 on the left. Never changes with selection. Newlines become line breaks.
descriptionstringnoneStatic paragraph on the right. When set, member bios are not shown.
pointsTeamHeroPoint[][]Footer row of label + white icon. Replaces name, role, tenure and the contact link. Icon is a name (wand, shirt, sun, expand, sparkles, camera, image) or an inline svg string.
membersTeamMember[]requiredname, role, optional description (bio, used only without a static description), image (full-bleed), optional avatar (thumbnail).
tenurestringnoneStatic footer line, visible from md up. Omit to hide.
contactLabelstring"Contact"Footer link text.
contactHrefstringnoneFooter link target. Omit to hide the link.
initialnumber0Member shown on load.
intervalnumber1500Milliseconds between auto-advances. Pauses on hover, focus, hidden tab or off-screen; restarts after a click; off for reduced motion. 0 disables.
classstring""Extra classes on the section.

Component playground

CollapsingStack

A numbered list of panels: title, paragraph and pill tags on the left, a looping video on the right. As you scroll, each panel slides up over the one before it, folding the list into a stack of headings with a strip of each video still showing.

  1. AI Photoshoots

    On-model and product photoshoots made with AI from the product photos you already have.

    • On-model
    • Product
    • Lifestyle
    • Catalog
    See photoshoots
  2. AI Video Production

    Campaign films and product videos made with AI, scripted and directed by our team.

    • Direction
    • Scripting
    • Campaign films
    • Product videos
    See AI video production
  3. AI Video Ads

    Short ads made with AI for paid social, cut in the formats each placement asks for.

    • Paid social
    • Hooks
    • Variations
    See AI video ads
  4. AI UGC Videos

    Creator-style videos made with AI avatars, talking to camera about your product.

    • AI avatars
    • Testimonials
    • Unboxing
    See AI UGC videos
<CollapsingStack
  items={items}          // { title, description, tags?, videoSrc, posterSrc?, href?, linkLabel? }[]
  rowHeight="6.25rem"    // height of one collapsed heading row (desktop)
  bgColor="hsl(var(--background))"
/>
PropTypeDefaultWhat it does
itemsStackItem[]requiredOne panel each, numbered in order. Landscape videos read best; they are cropped to 16:9.
rowHeightstring"6.25rem"How much of each folded panel stays visible. Each panel pins this much lower than the last. Phones use a fixed 4.5rem.
bgColorstring"hsl(var(--background))"Section and panel colour. Panels must be opaque to hide the ones beneath, so match the surrounding section.
classstring""Extra classes on the section.

The fold is pure CSS: every panel is position: sticky, with its top offset by its index times rowHeight below the site header, so no scroll listeners run. A small script plays only the videos on screen and pauses the rest; under prefers-reduced-motion they stay paused on their first frame or poster. Under 768px the video drops beneath the copy. Keep the list to about five panels, since each one adds a heading row to the pinned stack.

Component playground

BrandControlBanner

The closing call to action from the /start page. A rounded full-width frame with a muted looping video behind a left-aligned headline and one white button. Pass a Calendly URL and the button opens the booking popup; leave it out and the button is a plain link.

<BrandControlBanner
  videoSrc="/contact-card-hq.mp4"
  posterSrc="/contact-card-poster.webp"
  heading={"Let's build something\namazing together"}   // \n forces the line break
  ctaText={CTA.call.label}
  calendlyUrl={callHref(slug)}  // omit to use ctaHref instead
  videoObjectPosition="top"
/>
PropTypeDefaultWhat it does
headingstring"Your whole catalog,\nreimagined in minutes."Headline in the global heading-2 style, white. Newlines in the string become line breaks.
ctaTextstring"Try for Free"Button label. An arrow icon follows it.
calendlyUrlstringnoneWhen set, the button opens this Calendly event in the site's popup instead of navigating.
ctaHrefstring"/start"Link target used only when calendlyUrl is not set.
videoSrcstring"/contact-card-hq.mp4"H.264 fallback source.
videoSrcWebmstring"/contact-card-hq.webm"Offered first; browsers that cannot play it use videoSrc.
posterSrcstring"/contact-card-poster.webp"Still shown before the video loads.
videoObjectPositionstring"center"CSS object-position for the video, e.g. "top" to keep faces in frame on tall phones.
bgColorstring"#ffffff"Colour of the section around the rounded frame. Match it to the section above.

The frame is 21:9 on phones stretching to 21:7 on desktop, with a 4:5 portrait crop under 640px so the headline and button have room. A left-to-right gradient keeps the text legible over the video; on phones it runs top-to-bottom instead. The video is muted, looped and marked decorative, so nothing here is read by screen readers except the headline and the button. The Calendly popup is opened by a click handler that the Header's services menu binds once per page, so the banner needs Header on the same page and no script of its own.

<BrandLogoCarousel
  brands={brands}                    // { name, imageUrl }[], defaults to the 15 client logos
  speed={45}                         // seconds for one full loop
  ratingText="Trusted by 200+ brands"
  showStars={true}
/>
PropTypeDefaultWhat it does
brands{ name, imageUrl }[]the 15 logos in /public/compressed-logoname is the hover tooltip and the image alt. The list is duplicated once so the loop has no seam; eight or more read best.
speednumber45Seconds for one full pass. Lower is faster.
ratingTextstring"Trusted by 200+ brands"Uppercase line after the stars. Pass "" to drop the stars and the line together.
showStarsbooleantrueHide the five stars but keep the text.

Pure CSS marquee, no script: the track holds the list twice and translates by half its width, so the loop is seamless as long as every card keeps the same margin. The edge fades are overlays painted with the surface token rather than a mask, so the tooltip can rise above the row without being clipped. On touch devices the logos show in full colour and tooltips are dropped. Under prefers-reduced-motion the row stops and wraps into a centred grid.

Component playground

AccordionHero

The hero from /animated-video-production. Copy on the left: overline, H1, one paragraph and two buttons. On the right, a horizontal accordion of tall media panels: every panel rests as a thin sliver, and hovering one (tapping on touch) expands it to show the full frame while the rest collapse. A teal focus frame with crosshair lines glides along to sit over the open panel. Behind it, the hover-flash box grid and the home page's floating tiles, passed in through the background slot.

Animated video studio

Studio-grade animation, without the studio timeline

Custom motion graphics, 2D and character animation, kinetic typography, and illustrated explainers, delivered in days for a fraction of a traditional studio's cost. AI accelerates the frames. Human art direction owns the style, the story, and your brand.

Same hero with mediaSide="left": the panels take the wide column on the left and the copy moves to the right. The tiles are mirrored into the left gutter to match. Source order does not change, so the H1 still comes first for screen readers, and under 1000px both variants stack copy over panels.

Animated video studio

Studio-grade animation, without the studio timeline

Custom motion graphics, 2D and character animation, kinetic typography, and illustrated explainers, delivered in days for a fraction of a traditional studio's cost. AI accelerates the frames. Human art direction owns the style, the story, and your brand.

// Panels, left to right. Strings or { src, type?, alt?, poster? }.
const heroMedia = ["/video/1.mp4", "/video/2.mp4", "/video/3.mp4"];

// Inline SVG shown before the eyebrow text. Any icon works; it takes the
// eyebrow's colour through currentColor.
const heroEyebrowIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ...>...</svg>`;

// Floating tiles. Each one is an input: the label, the icon or image, where it
// sits, how fast it drifts on scroll, its size, entry delay and tilt.
const gutter = "((100% - 72rem) / 2)";   // free space beside the 72rem content box
const heroTiles = [
  { type: "icon",  iconName: "BarChart3",             label: "Growth",          x: `calc(100% - ${gutter} - 60px)`, y: "8%", parallaxSpeed: 0.5,  size: "lg", delay: "1.2s", rotate: 4 },
  { type: "icon",  iconName: "Users",                 label: "Models",          x: `calc(100% - ${gutter} + 30px)`, y: "30%", parallaxSpeed: 0.5,  size: "sm", delay: "1.6s", rotate: 5 },
  { type: "icon",  iconName: "FileSpreadsheet",       label: "Brand",           x: `calc(100% - ${gutter} - 30px)`, y: "52%", parallaxSpeed: 0.4,  size: "sm", delay: "1.8s", rotate: -4 },
];

<AccordionHero
  eyebrow="Animated video studio"
  eyebrowIcon={heroEyebrowIcon}  // optional, sits before the eyebrow text
  title="Studio-grade animation, without the studio timeline"
  description="Custom motion graphics, 2D and character animation..."
  primaryCta={{ label: CTA.call.label, href: callHref(slug), external: true }}
  secondaryCta={{ label: "See animation examples", href: "#portfolio" }}
  media={heroMedia}            // ({ src, type?, alt?, poster? } | string)[], first one opens on load
  initialIndex={0}
  aspect={9 / 16}              // width / height of the media, sizes the open panel
  audio={true}                 // open panel plays with sound after the first click or tap
  mediaSide="right"            // "left" mirrors it: panels left, copy right
>
  <Fragment slot="background">
    <BackgroundBoxes />
    <div class="absolute inset-0 hidden min-[1400px]:block pointer-events-none">
      <FloatingTiles tiles={heroTiles} wash={false} />
    </div>
  </Fragment>
</AccordionHero>
PropTypeDefaultWhat it does
titlestringrequiredThe page H1, Manrope 800, balanced. There should be exactly one of these per page.
eyebrowstringnoneOverline above the H1 in the global heading-overline style.
eyebrowIconstringnoneInline SVG markup shown before the eyebrow text. Inherits the eyebrow colour via currentColor; size is set by the --ah-eyebrow-icon custom property (1.125rem).
descriptionstringnoneParagraph under the H1, Inter, capped at 34rem.
primaryCta{ label, href, external? }noneFilled button. external opens a new tab with rel=noopener.
secondaryCta{ label, href, external? }noneOutlined button beside the primary. Both centre under the copy on phones.
media({ src, type?, alt?, poster? } | string)[]requiredPanels, left to right. type is inferred from the file extension when omitted. Videos are muted, looped and decorative; images use alt.
initialIndexnumber0Panel expanded on first paint.
collapsedWidthnumber56Sliver width in px on desktop. Shrinks on its own if many panels would starve the open one below 45% of the row.
collapsedWidthMobilenumber28Sliver width in px under 1000px.
gapnumber6Space between panels in px.
maxExpandedWidthnumbernoneUpper bound on the open panel in px. Omit to let aspect decide.
aspectnumber9 / 16Width over height of the media. The open panel is sized to show the whole frame at the row height.
audiobooleantruePlay the open panel's video with sound once the visitor has clicked, tapped or pressed a key. Until then, and for every collapsed panel, playback is muted.
stageHeightstring"clamp(440px, 64vh, 640px)"Height of the panel row on desktop, any CSS length.
stageHeightMobilestring"clamp(300px, 52vh, 420px)"Height of the panel row under 1000px.
mediaSide"left" | "right""right"Which side the panels sit on at desktop widths. "left" mirrors the layout: panels in the wide left column, copy on the right. Source order is unchanged, and under 1000px both variants stack copy over panels.
slot="background"markupnoneDecorative layer behind the whole hero, hidden under 1024px. BackgroundBoxes and FloatingTiles here; anything absolutely positioned works.
slot="lead"markupnoneContent between the description and the buttons. Service pages put the 4 hero features here (3 words max each, icon + label), then the BrandLogoCarousel trust row. All 4 features use the same filled pill: never give the first one (or any one) a primary style, a full-width row or a different weight. The trust row keeps the founders line on (Y Combinator, IIT Bombay) and shrinks the logos to 104×56px cards with no side margin. Service page heroes pass no primaryCta or secondaryCta, because the lead slot already fills the column.
default slotmarkupnoneExtra content under the buttons in the copy column.
classstring""Extra classes on the outer section.

FloatingTiles props (the tiles in the background slot)

PropTypeDefaultWhat it does
tilesTileProps[]the home page's eight tilesOne entry per tile. The four here are the home page's right-hand set, placed against the right gutter so they clear the accordion.
washbooleantrueWhite radial wash behind the tiles. Off here so the box grid shows through.

TileProps (one tile)

FieldTypeDefaultWhat it does
labelstringrequiredCaption under the icon or image, e.g. "Growth", "Brand", "Models".
type"icon" | "image"requiredIcon tile or photo tile.
iconNamestringnoneLucide icon name for icon tiles, e.g. "BarChart3", "Users", "FileSpreadsheet".
imageSrcstringnonePhoto for image tiles, cropped square.
x / ystringrequiredPosition of the tile's top-left corner, any CSS length. Percentages are of the hero; calc() against the gutter keeps a tile beside the content box.
parallaxSpeednumberrequiredHow far the tile drifts as the page scrolls. Higher moves more.
size"sm" | "md" | "lg" | "xl""md"Tile padding and icon or image size.
delaystringrequiredEntry animation delay, e.g. "1.2s". Stagger these so the tiles do not all pop at once.
rotatenumber0Resting tilt in degrees.

Motion is a plain CSS transition on left and width with one slow decelerating ease; a small script recomputes pixel positions on focus change and on resize. Each video is rendered at the open panel's width and centred, so a sliver is a live crop of the same frame it reveals and nothing rescales mid-transition. Only the open panel's video plays, and only while the stage is on screen and the tab is visible. Panels are keyboard reachable: Tab to a panel, then the arrow keys move along the row. Under 1000px the copy centres above the stage and the crosshairs clip to it. Under prefers-reduced-motion the transitions are off and videos stay on a still frame. The background slot is pointer-transparent except for the tiles, so the box grid still flashes under the cursor through the empty space around the copy.

Component playground

ScrubHero

The pointer-tracked character from /animated-video-production. A full-viewport background video is scrubbed by the cursor's horizontal position (finger position on touch): the clip is a head turn, first frame looking left, middle frame straight, last frame right. The cursor over the character's head lands on the straight frame, and the head turns toward the cursor as it moves away. In the copy column: one or two short paragraphs set exactly like the CardDealReveal centre card (Manrope 700, large, tight leading), then the same four equal feature pills AccordionHero's lead slot uses on the service pages. No CTA buttons. The default clip is the felt mascot below.

Meet your brand mascot, made with AI. Move your cursor and he follows.

Same face, same voice, in every video you post.

  • Locked character sheet
  • Brand voice
  • Launch video
  • Series ready
<ScrubHero
  videoSrc="/mascot-video.mp4"     // head-turn clip: left, straight, right
  characterX={0.72}                // where the head sits across the frame (0..1)
  tone="light"                     // "dark" for dark footage: white text and pills, black wash
  text={[                          // one or two short paragraphs, card-deck type
    "Meet your brand mascot, made with AI. Move your cursor and he follows.",
    "Same face, same voice, in every video you post.",
  ]}
  features={[                      // exactly four, 3 words max, all the same pill
    { label: "Locked character sheet", icon: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>' },
    { label: "Brand voice",           icon: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>...' },
    { label: "Launch video",          icon: '<rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/>' },
    { label: "Series ready",          icon: '<rect x="2" y="6" width="14" height="12" rx="2"/><path d="m22 8-6 4 6 4Z"/>' },
  ]}
/>
PropTypeDefaultWhat it does
videoSrcstring"/mascot-video.mp4"The clip that gets scrubbed. It must be a head turn: first frame looking left, middle frame straight ahead, last frame looking right. Muted, decorative, preloaded in full so seeks land instantly.
characterXnumber0.72Horizontal position of the character's head as a fraction of the video's width. The script replicates object-fit: cover and object-position to find where that column is painted, so the cursor over the head gives the straight frame at any viewport size.
textstring | string[]two mascot linesOne or two short paragraphs in the copy column, set like the CardDealReveal centre card: Manrope 700, clamp(1.5rem, min(3.1vw, 4.6vh), 2.75rem), line-height 1.15, foreground colour. Keep each under about 14 words so both fit on short screens. Say the work is made with AI.
features{ label, icon }[]the brand mascot page's fourFour feature pills under the text, 3 words max each, icon plus label. Same filled pill as the service page heroes: teal text on a 10% teal fill with a 22% teal border. All four are identical in style; never give the first one a different weight or fill. icon is the inner markup of a 24x24 lucide-style stroke icon.
tone"light" | "dark""light"Copy treatment. light: foreground text, teal pills, white-to-transparent wash on the copy side, for pale footage like the mascot. dark: white text, translucent white pills, black-to-transparent wash, for dark footage.
labelstring"Meet your brand mascot"Accessible name of the section.
classstring""Extra classes on the outer section.

The section is 100svh tall with a 560px floor. On desktop the mouse is tracked anywhere on the page while the section is on screen; on touch, the finger over the section itself. Each move sets a target frame and the video eases toward it a few percent per animation frame, waiting for the previous seek to finish before issuing the next, so the head never stutters. On load it seeks to the straight frame so the character is not caught looking sideways. The pills rise in one after another with the same stagger as the service page heroes. Under prefers-reduced-motion the pills are simply visible; the scrub itself still follows the pointer.

Component playground

CardDealReveal

Three acts in one scroll story. Three cards under a heading fan over each other and sink beneath a paper panel. In the panel the copy reveals word by word, with chosen keywords held in coloured pills. Then the same three cards fly back out, settle in a row and flip to list what each one includes. This is the "three layers" section on /ai-music-videos.

Rule: every card label is an outcome or a feature the client gets from us ("New shots", "Lip sync", "Detail check"), never a kind of tool, provider or alternative ("AI ad generator", "Avatar tool", "UGC creator", "Production house"). The deck is not a comparison; that goes in a comparison table. The act 3 heading names what the cards deliver. On the 24 video pages and the hub, all three acts run.

Every music video is built in three layers : motion, lyrics, and delivery.

We start with your track, not a template. The waveform decides the cuts: kicks and snares trigger the motion, drops land as glow and pulse, and every lyric hits the screen in time with the vocal, not eyeballed and slightly off.

Then we finish it for release. Visualizers loop without a seam, audio levels stay intact, and one concept exports in every ratio your fans watch in, so the same video looks intentional on a phone, in a feed, or on a TV.

What each layer includes

Audio-reactive visualizer still
Motion
01
Motion01
  • Kick & snare hits
  • Drop triggers
  • Glow & pulse
  • Shake & parallax
  • Beat-timed cuts
  • Driven by the waveform
Lyric video still
Lyrics
02
Lyrics02
  • Word-level timing
  • Typographic motion
  • Highlights
  • Reveals
  • Timed to the line
  • Synced to the vocal
Music video still
Delivery
03
Delivery03
  • Loops without a seam
  • Audio levels intact
  • 9:16, 1:1 and 16:9
  • Hook-first teasers
  • Upload-ready files
  • Sized per channel
const syncKeywords = [
  { word: "waveform", emoji: "🎚️" },   // plain strings work too: "waveform"
  { word: "lyric", emoji: "🎤" },
  { word: "loop", emoji: "🔁" },
  { word: "ratio", emoji: "📱" },
];

const syncLayers = [
  { label: "Motion", image: "/thumbnail/all/52.webp", alt: "...", items: ["Kick & snare hits", ...] },
  { label: "Lyrics", video: "/ai video/vid1.mp4", items: [...] },   // muted, looping clip on the face
  { label: "Delivery", items: [...] },                              // no media: solid ink tone
];

<CardDealReveal
  title="Every music video is built in three layers: motion, lyrics, and delivery."
  highlight="three layers"        // run of title painted with the gradient
  paragraphs={syncParagraphs}     // string[], two paragraphs of 40 to 50 words
  keywords={syncKeywords}         // (string | { word, emoji? })[], held in coloured pills
  outTitle="What each layer includes"
  outHighlight="each layer"
  cards={syncLayers}              // { label, items, number?, image?, video?, alt? }[], exactly three
  textScroll={3}                  // viewport heights the copy stays pinned
  cardScroll={3}                  // viewport heights the card row stays pinned, a third per card
/>

// Subsets. Each act only needs its own props.
<CardDealReveal acts="in" title="..." cards={syncLayers} />            // cards sink under the next section
<CardDealReveal acts="text" paragraphs={syncParagraphs} keywords={syncKeywords} />
<CardDealReveal acts="in-text" title="..." cards={syncLayers} paragraphs={syncParagraphs} />
PropTypeDefaultWhat it does
acts"all" | "in" | "text" | "in-text""all"Which acts render. "in" is the card row alone: the cards clip at the block's bottom edge, so they read as sinking under whatever section follows. "text" is the reveal panel alone. "in-text" is the first two acts. Props marked per act are only needed when that act renders.
titlestringact oneHeading above the cards in act one, in the global heading-2 style, balanced.
highlightstringnoneA run of title painted with the site's gradient text. Must appear in the title verbatim.
eyebrowstringnoneOptional overline above that heading.
paragraphsstring[]act twoCopy for the pinned reveal. Split into words at runtime from a data attribute, so re-mounting after a view transition never glues words together.
keywords(string | { word, emoji? })[][]Matched case-insensitively with punctuation stripped. Pills cycle the primary, a light tint of it, and paper. An emoji renders inside the pill directly after the word, and any trailing punctuation moves out after the pill: "falls," shows as "falls👗," never "falls,👗". Write the copy normally; the split is automatic.
outTitlestringact threeHeading that rides up above the flipped cards in act three.
outHighlightstringnoneSame as highlight, for outTitle.
outEyebrowstringnoneOptional overline above outTitle.
cardsDealCard[]acts one, threelabel on the face, items on the back (six fit), optional number in place of 01, 02, 03. Optional image or video (muted, looping; video wins) fills the face under a short scrim, with alt for the image; without media the face is a solid tone (primary, tint, ink). Only the first three are used.
inStartstring"top top"ScrollTrigger start for act one, measured on the card block. The default waits for the block to reach the top of the viewport. "clamp(top 25%)" starts the cards moving while the block is still a quarter of the way down the screen; clamp keeps them at rest on load if the block already sits above that line.
textScrollnumber3Viewport heights the text panel stays pinned. The reveal starts the moment the panel enters the viewport, completes at 60% of the scroll and holds, fully readable, for the rest.
cardScrollnumber3Viewport heights the card row stays pinned. Paced as PhoneWheelSteps paces its steps: each card owns a third of it, flies in and flips over the first 55% of its third and rests for the last 45%, so each card is a place the page stops at.
classstring""Extra classes on the outer section.

Native sticky pins plus three scrubbed ScrollTriggers, no Lenis. Nothing is tweened on the elements: every frame writes positions from progress, so scrolling back plays it in reverse exactly. The styles are global on purpose because the word wrappers are created by the script, where Astro's scoped attribute would never reach. Below 1000px both card acts are pinned stacks: one card at a time, nearly the width of the phone, slides straight in from the right and stops for most of its stretch of scroll, then the next slides in over it and the earlier ones are nudged left, peeking out from under it. Act one stacks the faces, act three the backs, already turned. Under prefers-reduced-motion everything is static with the backs showing and the copy is fully revealed. Requires gsap, already in package.json.

acts="text": the reveal panel alone

We start with your track, not a template. The waveform decides the cuts: kicks and snares trigger the motion, drops land as glow and pulse, and every lyric hits the screen in time with the vocal, not eyeballed and slightly off.

Then we finish it for release. Visualizers loop without a seam, audio levels stay intact, and one concept exports in every ratio your fans watch in, so the same video looks intentional on a phone, in a feed, or on a TV.

acts="in": the cards sink under whatever follows

Every music video is built in three layers : motion, lyrics, and delivery.

Component playground

FaqAccordion

The homepage FAQ list. Each question opens on hover or keyboard focus, with the chevron turning and the answer easing to its exact height. Closing waits a third of a second so moving between questions does not flicker. Pure CSS, no script.

FAQs

Product shoots, replaced by AI in 24 hours

What are the requirements to generate a photoshoot?
You only need a product image to get started. Our AI will take care of the rest, creating stunning photoshoot results for you.
Is this better than ChatGPT / Gemini?
ChatGPT/Gemini give inconsistent images and need prompts. Kaptured.AI is an AI production studio for fashion brands: our team runs the whole shoot for you, with consistent models, backgrounds and images ready to publish.
Do AI-generated models have any copyright issues?
No. All our AI-generated models are completely original, and we do not violate any copyright laws.
Can I create a new model and background?
Absolutely. Our platform allows you to design custom models and backgrounds that perfectly align with your brand style.
How long does it take to create a photoshoot?
Most photoshoots are generated within minutes, making it up to ten times faster than traditional photography.
Can I keep the background consistent across all my images?
Yes. You can maintain the same background for individual outfits or multiple products. Simply use the reference image option, and our AI will generate all photos with a consistent background style.
<section class="w-full py-8 sm:py-10 md:py-16">   // no background: inherits the page's
  <div class="max-w-3xl mx-auto px-4 sm:px-6">
    <h4 class="heading-overline text-center">FAQs</h4>
    <h2 class="heading-2 text-center mt-2">Product shoots, <span class="gradient-text">replaced</span> by AI in 24 hours</h2>
    <FaqAccordion
      faqs={faqs}            // { question, answer }[]
      emitSchema={false}     // true to emit FAQPage JSON-LD from this list
    />
  </div>
</section>
PropTypeDefaultWhat it does
faqs{ question, answer }[]requiredOne card per pair. Answers are plain text.
emitSchemabooleanfalseEmit FAQPage JSON-LD for this list. Leave off on pages that already build their own schema, or that show several FAQ sets, so Google never sees two competing FAQPage blocks.
classstring"mt-6 sm:mt-8"Classes on the list wrapper. Passing your own replaces the default top margin.

The heading pair above the list is page markup, not part of the component, so each page writes its own. Overline "FAQs", then one benefit-led heading that carries the page keyword, like the one above: keyword first, gradient on the benefit, benefit taken from what the answers prove. Never "[Service] FAQs", "Frequently asked questions", "Common questions" or a list of the question topics. Data-driven pages pass it as a required faqHeading field. Rule lives in BRAND_VOICE.md.

The list is capped at the 3xl content width by the page, as on the homepage. The block has no background of its own: the section around it is transparent and only the cards are painted, so it takes the colour of wherever it sits. Height animates with grid-template-rows from 0fr to 1fr, which eases to the real content height instead of a guessed max-height.

const relatedServices = [
  { label: "AI Video Production", href: "/ai-video-production/" },
  { label: "Social Media Video Production", href: "/social-media-video-production/" },
  { label: "AI Video Creatives for Meta Ads", href: "/meta-ads/" },
  { label: "Luxury Video Production", href: "/luxury-video-production/" },
  { label: "AI Ad Creatives", href: "/performance-video-ads/" },
];

<RelatedServices
  services={relatedServices}    // { label, href }[]
  label="Related services"      // pass "" to drop the line
  align="center"                // or "start"
  divider={false}               // true adds a hairline above the block
/>
PropTypeDefaultWhat it does
services{ label, href }[]requiredOne pill per entry, in order. Four to six read best; more still wrap cleanly.
labelstring"Related services"Uppercase Manrope line above the pills. Also the nav's aria-label. Pass "" to hide the line.
align"center" | "start""center"Centre the label and pills, or left-align them. With start, from 1024px up the label and pills share one row.
dividerbooleanfalseHairline above the block in the border token.
classstring""Extra classes on the outer nav.

Left-aligned, with the divider

Rendered as a nav with a list, so crawlers and screen readers see it as site navigation rather than body copy. Pills use the card, border and primary tokens, so they sit on either the white or the off-white page sections without changes. The arrow is an inline SVG on currentColor, so there is no icon font to ship. Under prefers-reduced-motion the hover lift and arrow slide are off.

Component playground

FeatureGrid

A framed grid of feature cells: icon, Manrope title, short Inter description. Hover a cell and a soft wash fades in, the accent bar beside the title grows and turns teal, and the title nudges right. Three or four cells per row, one or two rows. Pure CSS, no script, transparent background.

Four per row, two rows (8 items)

What you get

Product shoots, replaced by AI

Every deliverable is made with AI from the product photos you already have.

  • On-model shoots made with AI

    Models, sets and lighting cast to your brief, generated from the product photos you already have.

  • 100% product match

    Every stitch, print and colourway stays true to the sample, checked frame by frame before delivery.

  • Unlimited free revisions

    Change the model, the set or the crop as often as you need until the shoot is right.

  • Campaign films made with AI

    Scripted and directed by our team, then produced with AI in the length and format the placement asks for.

  • Every marketplace crop

    Amazon, Shopify, Zalando and your own store, each in its own ratio and background from one shoot.

  • One brand look everywhere

    The same colour grade, models and sets across listings, social and ads, so the catalogue reads as one.

  • Ad variations for paid social

    Hooks, formats and edits cut for each placement, so you test creative instead of reshooting it.

  • Turnaround in <2 days

    No studio bookings, no shipping samples, no waiting on a crew. Files land in your inbox.

const features = [
  { icon: "camera", title: "On-model shoots made with AI", description: "Models, sets and lighting cast to your brief..." },
  // ... 3, 4, 6 or 8 items. icon is a built-in name or a raw <svg> string.
];

<FeatureGrid
  features={features}        // { title, description, icon?, href? }[]
  columns={4}                // 3 or 4 per row; rows follow the item count (1 or 2)
  eyebrow="What you get"     // optional header, all three lines are optional
  title="Product shoots, replaced by AI"
  highlight="replaced"       // painted with the site's gradient text
  description="Every deliverable is made with AI from the product photos you already have."
  align="center"             // or "start"
/>
PropTypeDefaultWhat it does
featuresFeatureItem[]requiredtitle, description, optional icon and href. Pass 3, 4, 6 or 8 so every row is full; a partial row loses its right-hand borders and logs a dev warning.
columns3 | 44Cells per row from 1024px up. Four-wide drops to two columns on iPads; three-wide keeps all three from 768px so one row never breaks into 2 + 1. Phones stack one column.
eyebrowstringnoneTeal uppercase line above the heading, in the global heading-overline style.
titlestringnoneHeading in the global heading-2 style. Omit eyebrow, title and description to render the grid alone.
highlightstringnoneA run of title painted with gradient-text. Must appear in the title verbatim.
descriptionstringnoneParagraph under the heading, in the heading-subtitle style.
align"center" | "start""center"Header alignment only. The grid is always full width.
classstring""Extra classes on the outer section.

FeatureItem (one cell)

FieldTypeDefaultWhat it does
titlestringrequiredManrope 700, 1.125rem. Two to five words sits on one or two lines at four-up.
descriptionstringrequiredInter 0.875rem in muted-foreground, capped at 20rem wide.
iconstringnoneBuilt-in names: sparkles, camera, clapperboard, palette, zap, shield-check, globe, clock, layers, users, refresh, badge-check, image, video, wand, layout-grid, package, megaphone. Or any raw <svg> string on currentColor.
hrefstringnoneMakes the whole cell a link. Focus then triggers the same wash, bar and nudge as hover.

Three per row, one row (3 items), no header

  • On-model shoots made with AI

    Models, sets and lighting cast to your brief, generated from the product photos you already have.

  • 100% product match

    Every stitch, print and colourway stays true to the sample, checked frame by frame before delivery.

  • Unlimited free revisions

    Change the model, the set or the crop as often as you need until the shoot is right.

Borders are drawn per cell: a right rule on every cell, a left rule on the first of each row, a bottom rule on every row but the last, so the block reads as one frame at every width without an outer box. The hover wash rises from the bottom in the top row and falls from the top in the second, as in the reference, but at about half its strength; set --fg-wash (0 to 1) on the section to tune it. Hover styles sit behind a hover: hover media query, so touch devices never get a stuck highlight. Under prefers-reduced-motion every transition is off. Combine columns and item count for the four layouts: 3 x 1, 3 x 2, 4 x 1 and 4 x 2.

Component playground

RadialMenu

Six links as pie-donut wedges around a draggable joystick with the Kaptured logo in the middle. Hover a wedge, or push the pad toward it, and it flickers to its own pastel from the hero palette like a console selection. When the wheel scrolls into view the wedges blink on in a random order and the pad springs in. Three UI sounds: boot, hide and select. Wheel only: no overlay, no pull-up tab.

What we deliver

One shoot, six places it ends up

<RadialMenu
  eyebrow="What we deliver"
  title="One shoot, six places it ends up"
  highlight="six places"      // run of title painted with the gradient
  items={shootOutputs}        // { label, href, icon? | svg?, color?, image? | video?, poster? }[]
  size={640}                  // desktop diameter in px
  logo="/logo.webp"           // image in the centre of the pad
  selectOnRelease={false}     // true: releasing the pad over a wedge follows its link
  sounds={{ reveal: "/sfx/menu-open.mp3", hide: "/sfx/menu-close.mp3", select: "/sfx/menu-select.mp3" }}
/>
PropTypeDefaultWhat it does
itemsRadialMenuItem[]requiredlabel (Manrope), href, and either icon (a built-in name: tag, play, browser, megaphone, book, print, camera, layers, sparkle, users) or svg (your own 24x24 stroke path markup). Optional color is the lit colour; by default wedges cycle the hero pastel palette (blue, pink, green, yellow, purple, teal). Optional image, or video with an optional poster, is shown full-bleed and full-strength behind the wheel while that wedge is highlighted, by hover or by the joystick. A hard cut, no fade or wash. Videos are muted, loop, and only play while shown. The wheel divides evenly by count.
logostring"/logo.webp"Image shown in the centre of the joystick.
eyebrow / titlestringnoneOptional overline and heading above the wheel, in the global heading-overline and heading-2 styles.
highlightstringnoneA run of title painted with the site's gradient text. Must appear in the title verbatim.
hintstring""Optional line under the wheel with a pulsing glyph. Hidden when empty.
sizenumber640Desktop diameter in px. Under 1000px wide it becomes min(90vmin, 480px).
selectOnReleasebooleanfalseFollow the targeted wedge's link when the joystick is released over it.
sounds{ reveal?, hide?, select? } | falsethe three files in /sfxmp3 URLs for boot, hide and select. Pass false to mute. Every play is wrapped so autoplay policy or a missing file never throws. Browsers block audio until the first click or tap on the page, so the very first boot sound may be silent.
classstring""Extra classes on the outer section.

Wedge shapes are inline clip-path arcs computed from the wheel size, with a hairline joint on a fine pointer and a wider one on touch. The plates are flat, not frosted glass: clip-path plus backdrop-filter breaks on one engine or the other, and flat plates keep the shape everywhere. The joystick uses pointer events with capture, a 20px dead zone, a 25% drag radius and a 0.15 lerp back to centre. Resize is gated on a width change so a phone's URL bar cannot resize the wheel. Under prefers-reduced-motion the wedges render lit-on-hover with no strobe and no boot sequence. Requires gsap, already in package.json. Icons are inline SVGs, so there is no icon font to ship. The three sounds in /public/sfx are the reference component's demo files, licensed for demonstration only; replace them before this ships.

Component playground

ConvergingCardStack

Six scattered, rotated photo cards gather into one stack as you scroll through a section 1.6 viewports tall. The headline sits above the cards and never moves. Each card's start position is authored on the element, so rearranging the scatter is a markup change, not a script change.

Everything we shot ends up in the same pile

Profile against a bright wall, chin lifted
Dune crest at last light
Shallow water seen from above
Two hands holding cut ranunculus
Macro of woven linen
Back of a neck over a knitted collar
<ConvergingCardStack
  title="Everything we shot ends up in the same pile"
  highlight="same pile"        // run of title painted with the gradient
  cards={stackCards}           // { src, alt?, x?, y?, r? }[] | string[]
  scrollLength={1.6}
/>
PropTypeDefaultWhat it does
titlestringrequiredHeadline in the global heading-2 style, centred in the upper third of the stage.
highlightstringnoneA run of the title painted with the site's gradient text. Must appear in the title verbatim.
cards{ src, alt?, x?, y?, r? }[] | string[]requiredSquare cards. x and y are the start offset in px on a 1280px viewport, r the start rotation. Omit them to use the built-in scatter. Later cards land on top.
scrollLengthnumber1.6Height of the scroll track as a multiple of the viewport.
classstring""Extra classes on the outer section.

Native sticky pin plus one GSAP timeline scrubbed by ScrollTrigger (scrub 1.1 gives the cards a little travel after you stop scrolling; the site has no Lenis). Offsets scale with viewport width so the scatter stays on screen on phones. Under prefers-reduced-motion the track collapses and the cards render already stacked. Requires gsap, already in package.json. The stills above are the reference assets, hotlinked from motionprompts.dev for prototyping; replace them before this ships.

Component playground

RouteMap

A dot travels along a dotted route as you scroll. The line reveals itself behind the dot and each named stop lights up as it passes. The stage is pinned for 2.6 viewports. Stops are placed by asking the path itself, so changing the curve moves the line, the traveller and every label together.

Our process

Your shoot, from brief to delivery

0 % of the way

  1. Brief You send products and references
  2. Casting Models, sets and looks picked
  3. Studio shoot Every angle, every colourway
  4. Retouch Skin, fabric and colour checked
  5. Delivery Files land in your inbox
<RouteMap
  eyebrow="Our process"
  title="Your shoot, from brief to delivery"
  highlight="brief to delivery"  // run of title painted with the gradient
  stops={shootRoute}        // { name, detail?, image?, t? }[], t = fraction of the route
  travellerImage="/..."     // optional: a circular still instead of the dot
  counterTotal={100}
  counterUnit="%"
  counterLabel="of the way"
/>
PropTypeDefaultWhat it does
stopsRouteStop[]requiredname (Manrope), optional detail line, optional image (cropped to a circle above the name, greyscale until lit), optional t from 0 to 1. Leave t off every stop to space them evenly. Three to six read best. Production pages use the eight icons in /route-stops/ (brief, inputs, ideas, script, cast, make, check, deliver). Rule: no two stops in one RouteMap may share an icon. When two steps map to the same icon, keep it on the step it fits best and give the other the next best fit from the set that is still unused on that page.
travellerImagestringnoneSwap the travelling dot for a circular still.
eyebrow / titlestringnoneOptional overline and heading in the pinned stage, top left, in the global heading-overline and heading-2 styles.
highlightstringnoneA run of title painted with the site's gradient text. Must appear in the title verbatim.
counterTotalnumber100Value the live counter reaches at the end of the route.
counterUnitstring"%"Printed after the counter. Ignored when counterTotal is the number of stops: the counter then reads "2/4".
counterLabelstringnoneShort label after the counter, in the small type on the same line, e.g. "steps" or "of the way". The name of the latest lit stop prints under it.
perStepnumber0.8Viewport heights of scroll each stop owns, as in PhoneFeatureIndex. The traveller rests on the stop for the first half and crosses to the next over the second half. The track is one viewport plus this times the count.
scrollLengthnumberHeight of the whole scroll track as a multiple of the viewport. Overrides perStep.
path / viewBoxstring / { w, h }S-curve, 1000 x 240The route at desktop widths. This is the only place the geometry lives.
mobilePath / mobileViewBoxstring / { w, h }flatter wave, 1000 x 420Swapped in at 760px and below so labels stop stacking.
classstring""Extra classes on the outer section.

Native sticky pin plus one scrubbed ScrollTrigger (scrub 0.6 supplies the smoothing, the site has no Lenis). The dotted line is uncovered with an SVG mask, not by shifting the dash array, so the dots stay put. Labels pick their side from the curve (above on a crest, below in a trough), fall back when the side runs out of room or collides with a neighbour, and are re-placed by a ResizeObserver on the SVG. Under prefers-reduced-motion the track collapses, the route draws complete and every stop is lit. Requires gsap, already in package.json.

Component playground

PhoneWheelSteps

9:16 stills rest on the rim of a giant wheel just below the viewport and roll through it as you scroll. An iPhone mockup sits in the centre. As each card nears it the card straightens, glides onto the screen, trims to the phone's proportion and holds there for a beat before swinging off to the left. The neighbours wait one gap either side of the phone, so the reader can tell they are inside a sequence. Heading top left, a readout of the current step top right, as on RouteMap. The stage is pinned for 3 viewports.

Our process

From product photo to a vertical still in five steps

  1. Model in a studio look, vertical frame
    01 Brief You send product photos and a few references
  2. Model against a plain backdrop, vertical frame
    02 Casting Model, set and lighting picked to match your brand
  3. Full-length fashion still, vertical frame
    03 Studio shoot Every angle and every colourway, shot in one go
  4. Close fashion still, vertical frame
    04 Retouch Skin, fabric and colour checked frame by frame
  5. Finished fashion still, vertical frame
    05 Delivery Files sized for your feed land in your inbox
const wheelSteps = [
  { src: "/Clothes Sample/9.webp",  alt: "...", label: "Brief",        detail: "You send product photos and a few references" },
  { src: "/Clothes Sample/10.webp", alt: "...", label: "Casting",      detail: "Model, set and lighting picked to match your brand" },
  { src: "/Clothes Sample/11.webp", alt: "...", label: "Studio shoot", detail: "Every angle and every colourway, shot in one go" },
  { src: "/Clothes Sample/12.webp", alt: "...", label: "Retouch",      detail: "Skin, fabric and colour checked frame by frame" },
  { src: "/Clothes Sample/13.webp", alt: "...", label: "Delivery",     detail: "Files sized for your feed land in your inbox" },
];

<PhoneWheelSteps
  eyebrow="Our process"
  title="From product photo to a vertical still in five steps"
  highlight="five steps"      // run of title painted with the gradient
  steps={wheelSteps}          // { src, alt?, label?, detail? }[] | string[], three to six
  perStep={0.8}               // viewport heights of scroll each step owns
  spread={0.9}                // desktop arc width as a fraction of a half turn
  gap={40}                    // px between the phone's edge and the neighbouring card
/>
PropTypeDefaultWhat it does
steps{ src, alt?, label?, detail? }[] | string[]requiredOne 9:16 still per step, in order. Other ratios are cropped to 9:16. label (Manrope) and detail (Inter) print in the top-right readout while that card is nearest the phone, and are the card's caption in the reduced-motion list. Three to six read best; only the first six are used.
eyebrow / titlestringnoneOptional overline and heading pinned top left of the stage, in the global heading-overline and heading-2 styles.
highlightstringnoneA run of title painted with the site's gradient text. Must appear in the title verbatim.
perStepnumber0.8Viewport heights of scroll each step owns, as in PhoneFeatureIndex. The card holds in the phone for the first half and the wheel turns over the second half. The track is one viewport plus this times the count. The only number to touch.
scrollLengthnumberHeight of the whole scroll track as a multiple of the viewport. Overrides perStep.
spreadnumber0.9Width of the arc on desktop as a fraction of a half turn. Under 760px the arc narrows to 0.55 on its own.
gapnumber40Space in px between the phone's edge and the neighbouring card at rest, on desktop. Phones use 16. The neighbour is never placed further out than 42% of the width from centre.
classstring""Extra classes on the outer section.

The radius is solved, not chosen: the design input is where the neighbouring card sits (one gap outside the phone's edge, capped at 42% of the width from centre) and R falls out of that, so the centre never empties on a narrow screen and the neighbours never drift to the edges of a wide one. It is recomputed on every refresh, so rotating a phone keeps the geometry honest. The phone is sized from the stage: from tablet up the chrome floats in the top corners and the wheel takes the whole stage, so the phone stands as tall as the viewport allows (capped at 820px and at 40% of the width). The arc is squashed vertically by 0.55 and the rotation damped by the same factor so cards lean rather than lie down, and the opacity falloff is gentle so both neighbours stay legible. Docking is a blend: within a fifth of the card spacing from centre the card is fully in the phone, and over the next third it eases between the arc pose and the dock pose. The dock pose is measured from the real screen element: the scale that covers it, the clip that trims the 9:16 card to the screen's 9:19.5 shape, and the screen's corner radius, all written with one clip-path per card per update. The phone body sits behind the cards and only the bezel ring sits above them, so a docked card is the screen. The readout always names the step nearest the phone, so it is never blank between docks. Native sticky pin plus one scrubbed ScrollTrigger (scrub 0.8 supplies the smoothing, the site has no Lenis) and overflow-x clip on the section so the off-frame cards never lengthen the page. Under prefers-reduced-motion the track collapses and the steps render as a numbered grid of stills with their captions. Requires gsap, already in package.json.

Component playground

PhoneFeatureIndex

A pinned index of post-production features. A large two-digit number on the left, the list of names with a triangle beside the current one, and an iPhone mockup showing a 9:16 still of that edit. Each feature owns one stretch of scroll. Leaving it, the number peels upward out of its mask in layers, the next number climbs up from below, the triangle hops a row, the name goes to ink and the phone crossfades. Scroll up and it all reverses. The stage is pinned for 0.8 viewports per feature.

Use it only for post-production. The rows are editing features: Captions, Motion graphics, Speed ramping, Sound effects, Background music, End cards, Transitions, Colour grading. Order them for the page, and pick a still for each that shows that edit on screen, and keep the five or more that matter for that video type. Place it once per page, after the section that lists what the client gets and before RouteMap, only on video pages where the edit carries the video (TV commercials, AI avatar, music, social, UGC, creator-style, spokesperson, founder, brand film, product commercial and launch, performance ads, explainers, comedy, SaaS). BRAND_VOICE.md has the page list. Never use it for formats, use cases, industries, ad angles or looks: use CubeFlipCards, RadialMenu or CollapsingStack for those. If a section above already lists an edit (colour grade, captions, graphics), move it here so each idea lives in one place. Pick the screen the video plays on: the phone for social, ads and avatar videos, and device="tv" with 16:9 stills for TV commercials.

Post-production

Post-production on every AI avatar video

  1. Captions
  2. Motion graphics
  3. Speed ramping
  4. Sound effects
  5. Background music
  6. End cards
  7. Transitions
  8. Colour grading
  1. Captions timed to speech in a vertical AI video
    01 Captions
  2. Titles and charts animated over an AI video
    02 Motion graphics
  3. Model mid-move where the footage speeds up and slows down
    03 Speed ramping
  4. Product close-up where a sound effect lands
    04 Sound effects
  5. Wide scene scored with background music
    05 Background music
  6. End card with the brand and a call to action
    06 End cards
  7. Frame at a scene transition
    07 Transitions
  8. Colour-graded frame from an AI commercial
    08 Colour grading
const phoneFeatures = [
  // Post-production features only. Still = a 9:16 frame showing that edit.
  { name: "Captions",         src: "/work-types/ugc.webp",                   alt: "..." },
  { name: "Motion graphics",  src: "/work-types/human-motion-graphics.webp", alt: "..." },
  { name: "Speed ramping",    src: "/work-types/one-take-ecom.webp",         alt: "..." },
  { name: "Sound effects",    src: "/work-types/meta-ads-quirky.webp",       alt: "..." },
  { name: "Background music", src: "/work-types/brand-films.webp",           alt: "..." },
  { name: "End cards",        src: "/work-types/meta-ads-storyboarding.webp", alt: "..." },
  { name: "Transitions",      src: "/work-types/multi-take-ecom.webp",       alt: "..." },
  { name: "Colour grading",   src: "/work-types/tv-ads.webp",                alt: "..." },
];

<PhoneFeatureIndex
  eyebrow="Post-production"
  title="Post-production on every AI avatar video"
  highlight="every AI avatar video"  // run of title painted with the gradient
  features={phoneFeatures}      // { name, src, alt? }[], post-production features only
  device="phone"                // "tv" for a 16:9 TV on a stand, with 16:9 stills
  perStep={0.8}                 // viewport heights of scroll per look
/>
PropTypeDefaultWhat it does
features{ name, src, alt? }[]requiredOne row per feature, in order. name is the list entry (Inter); src is the 9:16 still shown on the phone while that feature is current, other ratios are cropped. Numbers are 01, 02, 03 from the order. Three to ten read well.
eyebrow / titlestringnoneOptional overline and heading pinned top left of the stage, in the global heading-overline and heading-2 styles.
highlightstringnoneA run of title painted with the site's gradient text. Must appear in the title verbatim.
perStepnumber0.8Viewport heights of scroll each feature owns. The track is one viewport plus this times the count. Lower is brisker; below 0.5 the peel and the crossfade start to overlap.
device"phone" | "tv""phone"The screen the stills play on. "phone" is the 9:16 iPhone. "tv" is a 16:9 TV on a stand that fills the wide right-hand column (stacked under the names on phones); give it 16:9 stills. Use "tv" when the video plays on a TV, as on TV commercial production.
classstring""Extra classes on the outer section.

The numbers all sit in one spot, each inside its own mask exactly one glyph tall. Leaving a feature fires four tweens on that number's layers: the mask lifts in 0.3s, the two glyphs in 0.75s with the second trailing by 0.1s, and the digit wrapper in 0.5s after a 0.5s delay, all power2.out, all by a little more than the mask height, so the number peels apart on its way out rather than lifting as one block. The next number's climb is scroll-linked: it rises from under the stage over the last 65% of the previous feature's stretch and lands at centre as its own begins, which is when the triangle hops (0.3s, one row height), the name goes to ink on a soft primary fill, the connector line beside the list fills in the primary down to that row, and the phone crossfades (0.45s, with a slight settle from 1.04). The line and the fill are the reference's accent-as-a-surface idea in the site's teal instead of canary. Scrolling back past the peel point restores the number the same way in reverse. Tabular figures on the number so nothing jitters. The number sits at the left edge of the content column, the names are centred between it and the phone, and the phone sits at the right edge. From desktop up the heading floats in the top-left corner and the grid takes the whole stage, so the phone is measured from its cell and capped at 820px and at 42% of the width. Under 1000px the number is dropped and the names and the phone sit side by side; the stills still carry their numbers for assistive tech. Native sticky pin plus one scrubbed ScrollTrigger (scrub 0.6, the site has no Lenis) for the climb and discrete tweens for the peel, the hop and the crossfade. Under prefers-reduced-motion the track collapses, the names render as a plain list and the stills as a grid with their numbers. Requires gsap, already in package.json. The stills above are the 400px reference set in /ai photoshoot; swap them for full-size exports before this ships.

Component playground

ExplodedStack

A stack of flat parts comes apart along Z as you scroll, and each part names itself with a hairline leader running back to the stack. Use it when the argument is what the thing is made of. The layers are CSS discs in a shallow tilt, not a photograph, so they can actually separate. The stage is pinned for 2.4 viewports.

What a shoot is made of

Every shoot, part by part

0 / 6 parts apart

  • Your product photo A flat lay or a phone shot is enough
  • Set Studio, street or location
  • Colour grade Editorial and on-brand
  • Model Cast to your brief
  • Lighting Matched to the set and the fabric
  • Marketplace crops Amazon, Shopify, Zalando and more
<ExplodedStack
  eyebrow="What a shoot is made of"
  title="Every shoot, part by part"
  highlight="part by part"  // run of title painted with the gradient
  parts={shootParts}        // { name, detail?, tone? }[], top of the stack first
  tilt={46}                 // degrees, keep between 40 and 60
  scrollLength={2.4}
/>
PropTypeDefaultWhat it does
partsExplodedPart[]requiredname (Manrope), optional detail line (Inter, hidden on phones), optional tone (any CSS colour). Index 0 is the lid and finishes on top. Four to seven read best; past seven the labels crowd.
eyebrow / titlestringnoneOptional overline and heading in the pinned stage, top left, in the global heading-overline and heading-2 styles. The count of parts apart prints top right.
highlightstringnoneA run of title painted with the site's gradient text. Must appear in the title verbatim.
tiltnumber46rotateX of the stack in degrees. Past 60 the discs close into rings; under 40 the layers hide each other.
scrollLengthnumber2.4Height of the scroll track as a multiple of the viewport.
classstring""Extra classes on the outer section.

Each label is bound to a layer by index, never to a position: its anchor and its leader are read from that layer's live bounding box on every update, so adding or reordering a part can never leave a name pointing at the wrong thing. Separation is staggered per layer so the parts peel off one after another, with a resting separation so the closed object still reads as something that opens. Leaders anchor at the stack's edge, not at their own layer, so they stay one length while the object moves. Labels in a column are de-collided by 54px; the label moves, the leader still points at its layer. Default tones alternate the secondary, primary, foreground and muted tokens so every part can be counted on a white stage. Native sticky pin plus one scrubbed ScrollTrigger (scrub 0.6, no Lenis, same as RouteMap). Under prefers-reduced-motion the track collapses and the object is shown already apart with every label up. Requires gsap, already in package.json.

<RingGallery
  eyebrow="Film stills"
  title="One reel, thirty-two frames"
  highlight="thirty-two frames"  // run of title painted with the gradient
  images={reelImages}          // { src, alt?, caption? }[] | string[]
  previewRatio="3/2"           // the reference stills are landscape
/>
PropTypeDefaultWhat it does
images{ src, alt?, caption? }[] | string[]requiredSources cycle across the tiles. The first one is the resting still. caption shows under the still, falling back to alt.
countnumber32Tiles on the ring above 768px. The bottom label is written from this, never as literal copy.
mobileCountnumber18Tiles at 768px and below. The ring rebuilds when a phone rotates across the breakpoint.
height / mobileHeightnumber560 / 420Stage height in px above and at or below 768px. The ring is measured to fit inside it.
spinnumber180Degrees the ring turns while the stage crosses the viewport. 360 for a full turn.
previewRatiostring"3/4"Aspect ratio of the centred still. Sized against the ring's measured hole, so it never covers the fins.
eyebrow / titlestringnoneOptional overline and heading above the stage, in the global heading-overline and heading-2 styles. Omit both to hide the header row.
highlightstringnoneA run of title painted with the site's gradient text. Must appear in the title verbatim.
hintstring"Drag or scroll to spin the reel"Shown after the live frame count under the stage.
footnotestringnoneOptional line at the bottom right, under the stage.
classstring""Extra classes on the outer section.

Under prefers-reduced-motion the intro is skipped, the cursor parallax is off, the drag has no inertia and the scroll tracks 1:1. The preference is read live. Requires gsap, already in package.json. The stills above are the component's reference assets, hotlinked from motionprompts.dev for prototyping; replace them before this ships.

Default, native aspect ratios

<CurvedGallery
  eyebrow="Apparel"
  title="Studio shots generated from flat lays"
  highlight="flat lays"    // run of title painted with the gradient
  description="Each frame started as a single product photo."
  images={apparelImages}   // { src, alt, width, height }[] with mixed ratios
  heightRatio={1.6}        // strip height = 1.6 x card width, covers the 9:16 portraits
/>

Strip only, reversed direction, stronger rotation

<CurvedGallery
  images={jewelryImages}
  direction="right"
  speed={60}
  maxAngle={52}
  depth={120}
  cardWidth={240}
  heightRatio={1.34}
  frame={false}              // no shadow, nothing behind the image
/>

Forced 1:1 crop, unframed, near flat

<CurvedGallery
  images={apparelImages}
  ratio="1/1"          // crops every card to one shape
  frame={false}
  cardWidth={220}
  heightRatio={1}
  gap={10}
  maxAngle={16}
  depth={40}
  speed={30}
/>

CurvedGallery props

PropTypeDefaultWhat it does
images{ src, alt?, width?, height? }[] | string[]requiredCloned at runtime until the row is wider than the viewport. width/height reserve space before load.
eyebrow / title / descriptionstringnoneOptional header, in the global heading-overline and heading-2 styles. Omit all three to render the strip alone.
highlightstringnoneA run of title painted with the site's gradient text. Must appear in the title verbatim.
cardWidthnumber220Desktop card width in px. Height follows each image's ratio. Tablets get about 70% of it, phones about 45%, so several cards stay in view.
heightRationumber1.5Strip height as a multiple of card width. Set it to cover your tallest image.
ratiostringnoneForce one aspect ratio and crop, e.g. "3/4". Off by default.
gapnumber10Desktop space between cards in px. Scales down with the card on tablet and phone.
speednumber40Scroll speed in px per second.
direction"left" | "right""left"Travel direction.
maxAnglenumber42Y rotation in degrees for a card at the viewport edge.
depthnumber60How far edge cards recede along Z. Keep small so sizes stay even.
curvenumber0Extra downward bow at the centre of the row.
framebooleantrueDrop shadow under each image. No fill behind the card.
draggablebooleantruePointer and touch scrubbing.
pauseOnHoverbooleantrueHalts autoplay while the pointer is over the strip.
classstring""Extra classes on the outer section.
Chat avatar
Book a free creative call Talk to our creative team
Call
WA
Email