@finsweet/webflow-astro
v0.2.1
Published
Astro components reproducing Webflow's built-in elements — navbar, dropdown, tabs, slider, lightbox, forms, collection lists
Readme
@finsweet/webflow-astro
Astro components reproducing Webflow's built-in elements — navbar, dropdown, tabs, slider, lightbox, forms, collection lists.
A Webflow page is mostly ordinary HTML plus a set of built-in widgets whose
behaviour comes from Webflow's own runtime (webflow.js + jQuery). Migrating to
Astro drops that runtime and the widgets stop working. This package supplies them
again: the same markup, the same class names, the same ARIA — behaviour
reimplemented in TypeScript, no jQuery, nothing bundled from Webflow.
npm install @finsweet/webflow-astroAstro 7.3+ as a peer dependency.
Converting a Webflow site? Jump to
Migrating a Webflow project — it maps every Webflow
data-w-type onto a component, and lists the traps that fail silently.
⚠️ This package ships no CSS
Webflow's stylesheet stays the consuming project's responsibility. Include your Webflow export's stylesheets globally, in this order, or none of these components will look like anything:
---
// src/layouts/BaseLayout.astro
import "../styles/normalize.css";
import "../styles/webflow.css"; // widget base rules + the icon webfont
import "../styles/your-site.webflow.css";
---That split is deliberate: these components emit exactly the class names Webflow emits, so an existing Webflow stylesheet styles them unchanged. It also means a wrong class name would be a silently unstyled element — which is why every component's markup was copied from real published Webflow output rather than written from memory, and why the surprises are documented in the prop docs instead of smoothed over.
Usage
---
import {
Navbar,
NavbarContainer,
NavbarBrand,
NavbarMenu,
NavbarLink,
NavbarButton,
} from "@finsweet/webflow-astro";
---
<Navbar collapse="medium" duration={400}>
<NavbarContainer class="w-container">
<NavbarBrand href="/"><img src="/logo.svg" alt="" /></NavbarBrand>
<NavbarMenu>
<NavbarLink href="/" current>Home</NavbarLink>
<NavbarLink href="/about">About</NavbarLink>
</NavbarMenu>
<NavbarButton />
</NavbarContainer>
</Navbar>Every component:
- takes
class, merged after the Webflow classes, which are never replaced; - spreads any other attribute (
id,data-*,style,aria-*) onto its root; - takes content through slots, never text props;
- documents every prop — the prop docs are the API reference.
Every component is a named export of the package root. A single-component subpath also works, and is identical in behaviour and in typing — use it to keep a barrel import out of a file that needs only one component:
import Navbar from "@finsweet/webflow-astro/Navbar.astro";Collection-list helpers are their own subpath, because they are plain TypeScript rather than components:
import { paginate, readPageFromRequest } from "@finsweet/webflow-astro/collection-list";Types a consumer needs for their own data come from the root:
import type { LightboxItem } from "@finsweet/webflow-astro";Components
| Family | Components |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Layout | Container Grid QuickStack Cell Columns Column VFlex HFlex |
| Text | Heading List ListItem Link RichText CodeBlock HtmlEmbed WebflowImage WebflowIcon |
| Navbar | Navbar NavbarContainer NavbarBrand NavbarMenu NavbarLink NavbarButton |
| Dropdown | Dropdown DropdownToggle DropdownList DropdownLink |
| Tabs | Tabs TabsMenu TabsLink TabsContent TabsPane |
| Slider | Slider SliderMask Slide SliderNav SliderArrow |
| Lightbox | Lightbox |
| Forms | Form FormSuccess FormError FormLabel TextInput Textarea Select Checkbox Radio FileUpload ReCaptcha FormButton |
| CMS | CollectionList CollectionItems CollectionItem CollectionEmpty Pagination PaginationPrevious PaginationNext PaginationCount |
| Locales | LocaleList LocaleItems LocaleItem LocaleLink LocaleEmpty |
| Media | Lottie Rive Spline BackgroundVideo Video YouTube Facebook Twitter WebflowMap |
Collection lists
CollectionList renders structure only; pagination is a helper, so one component
works in a static build and inside a server:defer island.
---
// static — page number is a route segment
import { paginate } from "@finsweet/webflow-astro/collection-list";
const list = paginate(await getCollection("blog"), { limit: 10, page: 1 });
---
<CollectionList>
{!list.isEmpty && (
<CollectionItems>
{list.items.map((post) => <CollectionItem>{post.data.name}</CollectionItem>)}
</CollectionItems>
)}
{list.isEmpty && <CollectionEmpty />}
</CollectionList>⚠️ A static build cannot vary a prerendered file by query string, so Webflow's
?page=2 link shape does not survive it — pass href to PaginationPrevious /
PaginationNext and paginate by route. Query params work inside an island, where
readPageFromRequest(Astro.request) reads them.
Cloudflare Turnstile
Set PUBLIC_CF_TURNSTILE_SITE_KEY and Form renders a Turnstile widget inside the
form, holding the submit button disabled until Cloudflare returns a token. Unset, no
widget is rendered and forms submit normally. There is no prop and no attribute —
the same variable is read server-side (to decide whether to render the container)
and in the client script (which passes the key to turnstile.render()).
⚠️ The PUBLIC_ prefix is required. Astro inlines only prefixed variables into the
client bundle; without it the script would read undefined and the widget would
silently never appear. A Turnstile site key is public by design — the secret key
stays on your server, and verifying cf-turnstile-response is your endpoint's job.
reCAPTCHA
ReCaptcha takes a siteKey prop and falls back to PUBLIC_RECAPTCHA_SITE_KEY,
so a site with one key configures it once. With neither, it renders in Webflow's
own invalid-key state (g-recaptcha-error g-recaptcha-disabled
g-recaptcha-invalid-key) rather than looking configured but doing nothing.
⚠️ Unlike Turnstile, the key is emitted as data-sitekey — Google's api.js
auto-renders by reading it off the element. You must also add the script yourself;
neither this package nor Webflow's own forms module loads it:
<script src="https://www.google.com/recaptcha/api.js" async defer></script>Third-party runtimes
Lottie, Rive, Spline and WebflowMap need a runtime this package deliberately does
not bundle — they are loaded on demand, so a page without them pays nothing.
Each component's prop docs name what it loads and how to supply a key or pin a
version.
Not implemented
Site search (Search::SearchForm, SearchInput, SearchButton). Webflow's
search is served by its own index; an Astro site has none, so a migration has to
pick a backend first. Nothing is stubbed.
Form submissions have no endpoint. Form takes an action and drives the
success/error flow against it, but the endpoint is yours to write — see the TODO in
src/scripts/form.ts. There is deliberately no default: a wrong one would silently
POST user data at a 404.
Migrating a Webflow project
Everything below is the conversion cheatsheet: look up a Webflow element's type, emit what the right-hand column says. It is written for an agent converting a Webflow export, and it lives in this README so it travels with the installed package.
Identifying an element
Two keys, and they are not the same string:
| Source | Field | Example |
| --- | --- | --- |
| whtml.html (Designer markup) | data-w-type | Dropdown::DropdownWrapper |
| dom.json (internal schema) | type | DropdownWrapper |
WHTML namespaces the type; dom.json does not. Match on the bare type and you
match both. The table below is sorted by it.
Settings live in data-props-* JSON in WHTML and in dom.json's data object.
⚠️ Four types exist only in dom.json — DynamoList, DynamoItem,
LocalesList, LocalesItem. WHTML flattens them away, so a converter reading only
WHTML cannot see a collection list's item template. Read dom.json for anything
repeating.
⚠️ Webflow's published HTML is a post-JavaScript snapshot. Runtime ARIA
(aria-controls, aria-expanded), generated ids (w-dropdown-list-0), the navbar
overlay and inline style widths are added by webflow.js after load — they are
not in the static export. These components server-render the static half and assign
the runtime half themselves, so do not copy those attributes into your output.
The map
| Webflow type | Astro | Notes |
| --- | --- | --- |
| Layout | | |
| Block | <div> | No component. The most common element by far. |
| Section | <section> | No component. |
| Paragraph | <p> | No component. |
| Blockquote | <blockquote> | No component. Webflow gives it no w- class and no settings. |
| BlockContainer | Container | w-layout-blockcontainer w-container |
| Grid | Grid | |
| Layout | QuickStack | Grid definition is CSS, not props — see #w-node- below |
| Cell | Cell | No settings at all |
| Row | Columns | Row carries no span info; the columns do |
| Column | Column | span/medium/small/tiny, 1–12 |
| VFlex / HFlex | VFlex / HFlex | |
| DropTarget | a layout's <slot /> | Designer-only template slot, not an element |
| Text | | |
| Heading | Heading | level 1–6 |
| List / ListItem | List / ListItem | tag="ul"\|"ol", unstyled → w-list-unstyled |
| Link | Link | button → w-button, block → w-inline-block |
| RichText | RichText | Pass Webflow's HTML to html; rendered verbatim |
| Strong/Emphasized/Superscript/Subscript | <strong>/<em>/<sup>/<sub> | Inside rich text only |
| CodeBlock | CodeBlock | Highlighting is baked in at publish time |
| HtmlEmbed | HtmlEmbed | Prefer the html prop; children get compiled by Astro |
| DOM | the tag itself | Webflow's arbitrary-HTML escape hatch. Tag + data-props-attributes ({name,value}[]) |
| Image | WebflowImage | ImageMetadata → Astro <Image>; string → plain <img>. Named to avoid colliding with astro:assets' own Image, which it wraps. |
| Icon | WebflowIcon | Empty div; glyph comes from the icon webfont |
| Navigation | | |
| NavbarWrapper | Navbar | animation, collapse, duration, easing, easing2, docHeight, noScroll |
| NavbarContainer NavbarBrand NavbarMenu NavbarLink NavbarButton | same names | NavbarLink takes current → w--current |
| DropdownWrapper | Dropdown | hover, delay |
| DropdownToggle DropdownList DropdownLink | same names | Toggle content is a slot — caret included |
| TabsWrapper | Tabs | easing, fadeIn, fadeOut |
| TabsMenu TabsLink TabsContent TabsPane | same names | tab pairs a link to its pane |
| SliderWrapper | Slider | delay, animation, autoplay, infinite, disableSwipe, … |
| SliderMask SliderNav SliderArrow | same names | |
| SliderSlide | Slide | ⚠️ the one slider name that differs |
| LightboxWrapper | Lightbox | Media group serialises into a script.w-json payload |
| Forms | | |
| FormWrapper + FormForm | Form | One component renders both. action is required for JS submit |
| FormSuccessMessage / FormErrorMessage | FormSuccess / FormError | Also the success/error slot defaults |
| FormBlockLabel | FormLabel | Webflow emits no w-* class here |
| FormInlineLabel | — | Rendered by Checkbox/Radio; a <span>, not a <label> |
| FormTextInput | TextInput | |
| FormTextarea | Textarea | |
| FormSelect | Select | |
| FormCheckboxWrapper + Input | Checkbox | custom → the styled-div variant |
| FormRadioWrapper + Input | Radio | custom → the styled-div variant |
| FormFileUpload* (15 types) | FileUpload | One component renders the whole widget |
| FormReCaptcha | ReCaptcha | |
| FormButton | FormButton | wait → data-wait label swap |
| SearchForm/SearchInput/SearchButton | TODO — not implemented | Webflow's search is server-side; pick a backend first |
| CMS | | |
| DynamoWrapper | CollectionList | |
| DynamoList (dom.json only) | CollectionItems | hide → w-dyn-hide when empty |
| DynamoItem (dom.json only) | CollectionItem | repeater → w-dyn-repeater-item |
| DynamoEmpty | CollectionEmpty | |
| Pagination | Pagination | |
| PaginationPrevious PaginationNext PaginationCount | same names | Prev/next render nothing when out of range |
| LocalesWrapper | LocaleList | |
| LocalesList/LocalesItem (dom.json only) | LocaleItems/LocaleItem | |
| LocalesEmpty | LocaleEmpty | |
| — | LocaleLink | hreflang is the locale tag (es-ES), not its subdirectory (es) |
| Media & widgets | | |
| Animation | Lottie | |
| Rive | Rive | |
| Spline | Spline | |
| BackgroundVideoWrapper (+ 3 button types) | BackgroundVideo | One component renders the whole widget |
| Video | Video | Keep the embedly URL; don't download |
| YouTubeVideo | YouTube | |
| Facebook | Facebook | Pure iframe — no JS |
| Twitter | Twitter | Pure iframe — no JS |
| MapWidget | WebflowMap | Needs a Google Maps API key. Named to avoid shadowing the global Map constructor. |
Sub-types absorbed by a parent component
These data-w-types have no component of their own — one parent renders the whole
widget. Listed so a search for the exact type string lands somewhere.
| Webflow type | Rendered by |
| --- | --- |
| FormCheckboxInput | Checkbox |
| FormRadioInput | Radio |
| FormFileUploadWrapper, FormFileUploadDefault, FormFileUploadInput, FormFileUploadLabel, FormFileUploadText, FormFileUploadInfo, FormFileUploadUploading, FormFileUploadUploadingBtn, FormFileUploadUploadingIcon, FormFileUploadSuccess, FormFileUploadFile, FormFileUploadFileName, FormFileUploadRemoveLink, FormFileUploadError, FormFileUploadErrorMsg | FileUpload |
| BackgroundVideoPlayPauseButton, BackgroundVideoPlayPauseButtonPlaying, BackgroundVideoPlayPauseButtonPaused | BackgroundVideo |
| PaginationPreviousIcon | PaginationPrevious |
| PaginationNextIcon | PaginationNext |
Traps
#w-node-… ids are load-bearing CSS. Webflow writes grid placement into the
stylesheet, keyed on element id:
#w-node-_1e54fc0e-…-2869e666 { grid-template-columns: 1fr 1fr; }Copy the id verbatim onto the element or its layout silently does not apply. It
passes through {...rest}. This is also why QuickStack has no columns prop —
an inline style would outrank those rules, including inside media queries.
Images: don't reach for Astro's <Image />. WebflowImage deliberately uses
getImage() and writes its own <img>, because <Image> injects width,
height and decoding that Webflow never publishes — enough to change layout on
a site whose CSS sizes images by class. Pass an imported ImageMetadata and you
get Webflow's exact tag, with its own 500/800/1080/1600/2000/2600/3200 srcset ladder;
pass a string and you get a bare <img src>, as Webflow publishes for SVGs and
CDN-hosted images.
Custom checkbox/radio knobs go in the after slot. Webflow puts a styled
toggle after span.w-form-label, inside the <label> — a sibling, not a child:
<Checkbox name="analytics" id="analytics" labelClass="prefs_label">
Analytics<Fragment slot="after"><div class="prefs_toggle" /></Fragment>
</Checkbox>⚠️ Nesting it in the default slot instead looks identical in a structural diff and
breaks silently: a site whose label CSS is display: none hides the knob with it.
BackgroundVideo needs showPlayPause passed explicitly. It defaults off,
because Webflow publishes the control only when the author added the button. The
signal in the source data is a BackgroundVideoPlayPauseButton node.
A <div class="w-form"> can carry its own id. {...rest} on Form goes to
the inner <form>; the wrapper is reached with wrapperAttrs={{ id: "form" }}.
Tabs: current on the wrapper does nothing. webflow.js finds the starting
tab with .w--current on the links, never from the wrapper attribute. Set
current on the TabsLink (and matching TabsPane), not on Tabs.
CMS field bindings. Most fields bind into an existing element rather than adding one. Three that surprise:
- Number → an attribute on the item (
data-number="8"), never text. - Switch → no markup at all; it is a build-time filter. In Astro, a
getCollectionfilter callback. - Color → inline
styleon the item (border-color,background-color). - Multi-Image and Multi-Reference → a genuinely nested collection list.
- Reference → one field feeds several elements via dotted paths
(
reference.fullSlug,reference.icon,reference.name).
Pagination differs by build mode. Webflow uses ?page=2. A static Astro build
cannot vary a prerendered file by query string, so pass href to
PaginationPrevious/PaginationNext and paginate by route segment. Query params
work in a server:defer island — use readPageFromRequest(Astro.request).
Page scaffolding a converter must carry over: stylesheet order
(normalize.css, webflow.css, <site>.webflow.css), <html data-wf-page
data-wf-site>, and the inline script adding w-mod-js/w-mod-touch.
Component naming
Components take human names, not Webflow's internal ones — Lottie not
Animation, Form not FormWrapper, CollectionList not DynamoWrapper. The
src/components/ui/*.astro paths in Webflow export notes describe a consuming
project's layout, not this package's.
Contributing
CONVENTIONS.md is the contract every component follows. The
short version: class names come from Webflow's published output and the file wins
over your expectation; behaviour uses enhance() because an Astro <script> is
hoisted once per page and does not run per instance.
pnpm check # astro check + tsc
pnpm build # compiles src/lib → dist