Skip to content

Feature/storyblok asset poster - #2652

Merged
bramvanderholst merged 3 commits into
canaryfrom
feature/storyblok-asset-poster
Jul 20, 2026
Merged

Feature/storyblok asset poster#2652
bramvanderholst merged 3 commits into
canaryfrom
feature/storyblok-asset-poster

Conversation

@bramvanderholst

@bramvanderholst bramvanderholst commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Let a Storyblok video asset show a poster

Why

An autoplaying <video> paints nothing until it has buffered enough for its
first frame — and nothing at all when autoplay is blocked, as in iOS Low Power
Mode. A full-bleed video banner above the fold therefore starts out black, and
can stay black. There was no way to give it a poster.

What

Three coordinated changes, all minor (no breaking changes):

@graphcommerce/storyblok-ui

  • Asset gains a poster?: StoryblokAssetData prop, rendered as <video poster>.
    Ignored for non-video assets.
  • New assetWithPoster(value) helper narrows an asset field's value into
    { asset, poster }. It reads the convention of an asset value that carries an
    extra poster key:
    { "fieldtype": "asset", "filename": "", "poster": { "filename": "" } }
    Keeping the poster beside the asset rather than nesting both under a wrapper
    is deliberate: a custom field type storing that shape becomes a drop-in for a
    plain type: asset field
    — existing content stays valid, value.filename
    keeps working for consumers that ignore the poster, and switching a field over
    needs no content migration. The narrowing is unavoidable regardless: Storyblok
    has no JSONSchema for custom field types, so its type generator emits unknown
    for them.

@graphcommerce/image

  • New imageUrl(src, { width, quality }) builds an optimized image URL outside
    of a React tree — for the spots that need a bare URL string rather than an
    <Image>: <video poster>, CSS background-image, og:image. It routes
    through the configured loader exactly like <Image>, so the bytes are served
    and cached by the deployment's own optimizer instead of every visitor hitting
    the origin host. width is snapped up to the nearest configured size, since
    the optimizer rejects any width outside imageSizes/deviceSizes.

@graphcommerce/next-ui

  • HeroBanner now takes asset?: React.ReactNode and renders it, the way its
    sibling SpecialBanner already does, instead of rendering its own <video>
    from a raw videoSrc. This lets a banner hold an image or a video, and lets
    the caller attach a poster.
  • videoSrc is deprecated but still works (renders a bare autoplaying video,
    keeping the HeroBanner-video class), so the change is non-breaking. The only
    behavioural change on that path is the loss of the scroll parallax, along with
    the framer-motion / useScrollY / clientSize machinery it required.
  • Taking a node rather than data keeps next-ui free of any CMS — importing a
    CMS Asset into next-ui would invert the dependency direction (storyblok-ui
    and hygraph-ui depend on next-ui, not the reverse).

examples/magento-storyblok

  • Every asset field now reads through assetWithPoster() and forwards the
    poster to <Asset>. This makes the choice of field type a content decision:
    a plain type: asset and a custom field storing the poster convention narrow
    through the same call, so a project can switch any field between the two —
    either direction — without touching a component.

examples/magento-graphcms (one file)

  • RowHeroBanner moves off the deprecated videoSrc onto the new asset prop,
    passing Hygraph's own <Asset>. This is the second HeroBanner consumer, so
    it comes along with that change — Hygraph has no poster convention and doesn't
    use assetWithPoster().

Verification

Exercised end-to-end on a storefront running this branch: a hero-banner video's
<video> renders poster="/_next/image?url=…&w=1200&q=75", served by the app's
own optimizer as WebP (~16 KB from a 3840×2160 source, vs ~331 KB fetched
straight from the Storyblok CDN). Existing image and video content renders
unchanged. tsc shows no new errors in any touched package.

A `<video>` paints nothing until it has buffered enough for its first
frame, and nothing at all when autoplay is blocked (iOS Low Power Mode).
An autoplaying video banner therefore starts out black, and can stay
black. `Asset` now takes a `poster`, rendered as `<video poster>`.

Storyblok's `type: asset` has no room for a poster, so `assetWithPoster()`
reads the convention of an asset value carrying an extra `poster` key:

  { "fieldtype": "asset", "filename": "…", "poster": { "filename": "…" } }

Keeping the poster beside the asset rather than nesting both under a
wrapper is what makes a custom field type storing that shape a drop-in
for a plain asset field: existing content stays valid and
`value.filename` keeps working for consumers that ignore the poster. The
narrowing is unavoidable regardless — Storyblok has no JSONSchema for
custom field types, so its type generator emits `unknown` for them.

`@graphcommerce/image` gains `imageUrl()`, which builds an optimized URL
outside of a React tree for the spots that need a bare URL rather than an
`<Image>` — `<video poster>`, `background-image`, `og:image`. It routes
through the configured loader, so the bytes are served and cached by the
deployment instead of every visitor hitting the origin host; that matters
when the origin meters bandwidth. Width snaps up to the nearest
configured size, since the optimizer 400s on anything outside
`imageSizes`/`deviceSizes`.
…Poster

Each asset field in the example now goes through `assetWithPoster()`
instead of reading `blok.asset` directly, and forwards the resulting
`poster` to `<Asset>`.

This makes the choice of field type a content decision rather than a code
one: a plain `type: asset` and a custom field storing the poster
convention both narrow through the same call, so a project can switch any
field between the two — in either direction — without touching a
component. Fields that never hold a video simply resolve `poster` to
undefined.
`HeroBanner` rendered a `<video>` itself, from a raw URL. That meant it
could only ever hold a video, and left nowhere to hang a poster — the one
component where a poster matters most, being a full-bleed autoplaying
video above the fold.

It now takes `asset?: React.ReactNode` and renders it, exactly as its
sibling SpecialBanner already does. Positioning stays with the banner:
whatever is passed gets stretched to fill via `& img, & video`.

  - <HeroBanner videoSrc={asset.filename} … />
  + <HeroBanner asset={<Asset asset={asset} poster={poster} />} … />

Taking a node rather than data also keeps next-ui free of any CMS: the
Storyblok and Hygraph examples each pass their own `<Asset>`, both of
which already render images and video. Importing a CMS `Asset` into
next-ui would have inverted the dependency — storyblok-ui and hygraph-ui
depend on next-ui, not the other way round.

Non-breaking: `videoSrc` stays as a deprecated fallback that renders a
bare autoplaying video (keeping the HeroBanner-video class), and the
`animated`/`video` class parts stay exported. The only behavioural change
on the videoSrc path is the loss of the scroll parallax, along with the
framer-motion, useScrollY and clientSize machinery it required.
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 132d1e1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 86 packages
Name Type
@graphcommerce/next-ui Major
@graphcommerce/storyblok-ui Major
@graphcommerce/image Major
@graphcommerce/address-fields-nl Major
@graphcommerce/algolia-categories Major
@graphcommerce/algolia-insights Major
@graphcommerce/algolia-personalization Major
@graphcommerce/algolia-products Major
@graphcommerce/algolia-recommend Major
@graphcommerce/algolia-search Major
@graphcommerce/demo-magento-graphcommerce Major
@graphcommerce/ecommerce-ui Major
@graphcommerce/google-datalayer Major
@graphcommerce/google-playstore Major
@graphcommerce/googleanalytics Major
@graphcommerce/googlerecaptcha Major
@graphcommerce/googletagmanager Major
@graphcommerce/hygraph-dynamic-rows Major
@graphcommerce/hygraph-ui Major
@graphcommerce/lingui-next Major
@graphcommerce/magento-cart-checkout Major
@graphcommerce/magento-cart-coupon Major
@graphcommerce/magento-cart-email Major
@graphcommerce/magento-cart-items Major
@graphcommerce/magento-cart-payment-method Major
@graphcommerce/magento-cart-pickup Major
@graphcommerce/magento-cart-shipping-address Major
@graphcommerce/magento-cart-shipping-method Major
@graphcommerce/magento-cart Major
@graphcommerce/magento-category Major
@graphcommerce/magento-compare Major
@graphcommerce/magento-customer Major
@graphcommerce/magento-graphql-rest Major
@graphcommerce/magento-newsletter Major
@graphcommerce/magento-payment-adyen Major
@graphcommerce/magento-payment-afterpay Major
@graphcommerce/magento-payment-braintree Major
@graphcommerce/magento-payment-included Major
@graphcommerce/magento-payment-klarna Major
@graphcommerce/magento-payment-multisafepay Major
@graphcommerce/magento-payment-paypal Major
@graphcommerce/magento-payment-tokens Major
@graphcommerce/magento-product-bundle Major
@graphcommerce/magento-product-configurable Major
@graphcommerce/magento-product-downloadable Major
@graphcommerce/magento-product-grouped Major
@graphcommerce/magento-product-simple Major
@graphcommerce/magento-product-virtual Major
@graphcommerce/magento-product Major
@graphcommerce/magento-recently-viewed-products Major
@graphcommerce/magento-review Major
@graphcommerce/magento-search-overlay Major
@graphcommerce/magento-search Major
@graphcommerce/magento-store Major
@graphcommerce/magento-wishlist Major
@graphcommerce/mollie-magento-payment Major
@graphcommerce/framer-next-pages-example Major
@graphcommerce/framer-scroller-example Major
@graphcommerce/magento-graphcms Major
@graphcommerce/magento-open-source Major
@graphcommerce/magento-storyblok Major
@graphcommerce/framer-scroller Major
@graphcommerce/graphcms-ui Major
@graphcommerce/magento-cms Major
@graphcommerce/docs Major
@graphcommerce/browserslist-config-pwa Major
@graphcommerce/changeset-changelog Major
@graphcommerce/eslint-config-pwa Major
@graphcommerce/graphql-codegen-markdown-docs Major
@graphcommerce/graphql-codegen-near-operation-file Major
@graphcommerce/graphql-codegen-relay-optimizer-plugin Major
@graphcommerce/misc Major
@graphcommerce/next-config Major
@graphcommerce/prettier-config-pwa Major
@graphcommerce/typescript-config-pwa Major
@graphcommerce/cli Major
@graphcommerce/framer-next-pages Major
@graphcommerce/framer-utils Major
@graphcommerce/graphql-mesh Major
@graphcommerce/graphql Major
@graphcommerce/hygraph-cli Major
@graphcommerce/hygraph-dynamic-rows-ui Major
@graphcommerce/magento-graphql Major
@graphcommerce/react-hook-form Major
@graphcommerce/service-worker Major
@graphcommerce/image-example Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
graphcommerce-246 Ready Ready Preview Jul 20, 2026 7:32am
graphcommerce-247 Ready Ready Preview Jul 20, 2026 7:32am
graphcommerce-hygraph-dynamic-rows-ui Ready Ready Preview Jul 20, 2026 7:32am
graphcommerce-open-source Ready Ready Preview Jul 20, 2026 7:32am
graphcommerce-storyblok Ready Ready Preview Jul 20, 2026 7:32am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant