Form 01
A contact form with underline fields. Each label rests in the line of its field like a placeholder and floats up to its place when the field takes the focus or a value; a line is drawn under the field from the left on focus and leaves by the right; a tick draws itself at the end of a field that is right; a wrong field turns its line and label red, and its reason takes the place of the hint under it, then leaves at the keystroke that fixes it, so nothing below ever moves.
Documentation
-
Copy to Webflow
Add Geist and Geist Mono to your site's fonts (Site settings, Fonts, Google Fonts) before pasting: Webflow removes any font your site has not installed. -
Add custom CSS
Custom CSS in WebflowThis CSS already ships inside the Webflow embed, so a pasted section needs nothing added. Paste it yourself, in the page or the site custom code, only if the effect runs on several pages. The tutorial explains where.
See the tutorialThe look of the preview is theform-set-*classes of the paste: the fields at rest and the button. The Embed holds what moves, and it reads its colours from five variables,--form-set-ink,--form-set-ground,--form-set-rule,--form-set-accentand--form-set-error: set them on the form, or on anything above it, in a rule of your own. The send button is a Link block, because a Webflow submit button is an input and cannot hold the layers that move; the script makes it send the form, and Enter in a field does too. -
Add custom JS
Custom JavaScript in WebflowThis script already ships inside the Webflow embed, settings block included. You paste it yourself, before the closing body tag of the page or the site custom code, only when the effect has to run on several pages, and the tutorial says how to choose.
See the tutorial -
Use it on your own elements
How to add a custom attribute in WebflowA custom attribute is a pair, a name and a value, typed in the Settings panel of the element you selected, under Custom attributes. The name is what the script looks for, the value is what it reads. The tutorial shows the panel.
See the tutorialCustom attributesSelect the element on the canvas and open the Settings panel. Under Custom attributes, add a pair: the name on the left, the value on the right.Custom attribute What it does data-form-set Marks the form. It takes no value: leave the value field empty. In the paste it is on the Form inside the Form block. The settings below go on the same element. data-form-set-field On the box of one field: one label and one control (a text, email or phone input, a select, a textarea), with its hint when it has one. It takes no value. The engine writes the state on it and adds what it draws inside it. A control outside such a box is sent with the form but has no state and is not checked. [ Required ] data-form-set-field="check" On the label that holds a checkbox and its sentence. A required box left empty is refused with the words of the setting check. [ Optional ] data-form-set-submit On the send button: a submit button in plain HTML, a Link block in Webflow, which has no button that can hold layers. It takes no value. Without it the form is still checked when it is sent, by Enter for one, but nothing shows it leaving. [ Required ] data-form-set-hint On the line of help under a control. It takes no value. The reason takes its place while the field is wrong, then the hint comes back. A field without one gets a line from the engine, dressed like the first hint of the form. [ Optional ] data-form-set-fit On a field whose control is as wide as what is written in it, for a control set in a line of text. It takes no value. [ Optional ] data-form-set-required="Your name, please." On one field: its own words for a reason, in place of the form's. The same goes for email, tel, short, pattern and check. [ Optional ] data-form-set-select="native" On one field: leaves its select to the list of the system, in place of the list the engine draws. On the form it is the setting select, for every select at once. A touch screen gets the system's picker either way, and the native select is what the form sends in both cases. [ Optional ] data-form-set-dialog Optional, for a form that opens in a dialog as Form 03 does: on a layer that holds a panel, the panel holding the form. With no value the panel rises in the middle of the window; with the value sheet, data-form-set-dialog="sheet", it slides in from the right. The stylesheet lays the layer over the window and hides it until it is opened. [ Optional ] data-form-set-open On what opens the dialog: any button or link, anywhere on the page, as many as you like. It takes no value, or the id of the dialog when the page has several: data-form-set-open="quote". [ Optional ] data-form-set-close On what closes the dialog, inside it. It takes no value, or cross to draw a cross in place of its word, which stays its name for a screen reader. Escape and a click outside the panel close it too, and so does the form, the time of the setting closeAfter after it was sent. [ Optional ] data-form-set-state Written by the engine. On a field: up, focus, filled, valid, error, disabled, several at once. On the send button: loading, done or failed. On the form: done. On a dialog: open. A field whose list is open also carries data-form-set-list="open". Never written by hand; the stylesheet reads them, and so can a rule of yours. [ Optional ] SettingsEvery setting is a custom attribute too, added the same way and on the same element. An attribute always wins over the defaults written in the code.Attribute Default What it is data-form-set-send auto How the form leaves once every field is right. Options: auto lets a Webflow Form block send it to Webflow, and anywhere else posts the form to its action the usual way, the page changing; fetch posts the values to the action without leaving the page and reads the status of the answer. data-form-set-sending Sending The word on the button while the form is on its way. Options: any text, in the language of the page. data-form-set-sent Sent The word on the button once the form has left, beside the tick. Options: any text. data-form-set-failed Try again The word on the button when the send failed; the button can be pressed again. Options: any text. data-form-set-required This field is required. The reason under a required field left empty. Options: any text; data-form-set-required on one field gives it its own, "Your name, please.". data-form-set-email Enter a valid email address. The reason under a field of type email that does not hold an address. Options: any text. data-form-set-tel Enter a valid phone number. The reason under a field of type tel that does not hold a phone number, six digits at least. Options: any text. data-form-set-short A little more, please. The reason under a text shorter than the minlength of its control. Options: any text. data-form-set-pattern Check the format. The reason under a text the pattern of its control refuses. Options: any text; say the format expected, "Two letters, then four digits.". data-form-set-check Please tick this box. The reason under a required checkbox left empty. Options: any text. data-form-set-select drawn The list a select opens. Options: drawn, a list the engine draws under the control in the colours of the form, the native select staying underneath; native, the list of the system, which a touch screen gets either way. data-form-set-select="native" on one field leaves that select alone. data-form-set-close-after 2400 How long a dialog stays on the answer once its form is sent, in milliseconds. Only read by a form in a dialog. The way the form is sentNothing to connect: the paste is a native Webflow Form block, and Webflow receives what it sends. The entries are listed in Site settings, Forms, each field under the name of its label, and the address told of a new entry is set in the same place, under Form notifications. Entries only arrive from a published site, its webflow.io address included: the Designer and its preview send nothing. Once Webflow has the entry it hides the form and shows its Success message; when it could not, it shows its Error message and the button says Try again. Both are Webflow's own blocks, under the Form in the Navigator: select the Form block, open its settings and choose the state to see, restyle and rewrite each one. To add a field, duplicate a field block and give the new field its own name in its settings; to remove one, delete its block; Required is a switch in the same settings. Leave the Action of the form empty, or Webflow receives nothing. Only when the form is not a Webflow Form block do the attributes below apply.Custom attribute What it does action="https://..." On the form: the address the values are posted to, an endpoint of your own or one of a form service such as Formspree or Basin. Not a custom attribute in Webflow, where it is the Action field of the Form settings, to leave empty for Webflow to receive the entries. data-form-set-send="fetch" On the form: posts the values to the action without leaving the page, as FormData, with the header Accept: application/json. An answer in the 200s shows Sent, anything else Try again. It is the setting send, so it may also be set for the whole site. method="post" On the form: how the values are sent, POST when nothing is written. With GET and fetch they travel in the address.
Two files, no build step.
It runs in the browser only. Load it with a script tag in plain HTML, in a
client-side script in Astro, never in the frontmatter. In React, call
FormSet.mount(ref.current) in useEffect and
destroy() in its cleanup.
-
Tune it, either way
Per element with an attribute, or once for the whole site by editingDEFAULTSat the top of the file.Attribute Default What it is Lower Higher data-form-set-send auto How the form leaves once every field is right. Options: auto lets a Webflow Form block send it to Webflow, and anywhere else posts the form to its action the usual way, the page changing; fetch posts the values to the action without leaving the page and reads the status of the answer. data-form-set-sending Sending The word on the button while the form is on its way. Options: any text, in the language of the page. data-form-set-sent Sent The word on the button once the form has left, beside the tick. Options: any text. data-form-set-failed Try again The word on the button when the send failed; the button can be pressed again. Options: any text. data-form-set-required This field is required. The reason under a required field left empty. Options: any text; data-form-set-required on one field gives it its own, "Your name, please.". data-form-set-email Enter a valid email address. The reason under a field of type email that does not hold an address. Options: any text. data-form-set-tel Enter a valid phone number. The reason under a field of type tel that does not hold a phone number, six digits at least. Options: any text. data-form-set-short A little more, please. The reason under a text shorter than the minlength of its control. Options: any text. data-form-set-pattern Check the format. The reason under a text the pattern of its control refuses. Options: any text; say the format expected, "Two letters, then four digits.". data-form-set-check Please tick this box. The reason under a required checkbox left empty. Options: any text. data-form-set-select drawn The list a select opens. Options: drawn, a list the engine draws under the control in the colours of the form, the native select staying underneath; native, the list of the system, which a touch screen gets either way. data-form-set-select="native" on one field leaves that select alone. data-form-set-close-after 2400 How long a dialog stays on the answer once its form is sent, in milliseconds. Only read by a form in a dialog. Closes sooner; 0 closes it at once Leaves more time to read the word on the button; off keeps it open until the visitor closes it The way the form is sentOnce every field is right the form leaves in one of four ways. With anactionand nothing else, the browser posts it and the page changes. Withdata-form-set-send="fetch"it is posted to that action without leaving the page, and the button says Sent or Try again from the status of the answer. With a listener onformset:submitthat callspreventDefault(), the page sends the values itself and ends withdetail.done(true)ordetail.done(false). Inside a Webflow Form block, Webflow sends it. Theaction="#"of the markup is a placeholder and sends nothing.Custom attribute What it does action="https://..." On the form: the address the values are posted to, an endpoint of your own or one of a form service such as Formspree or Basin. Not a custom attribute in Webflow, where it is the Action field of the Form settings, to leave empty for Webflow to receive the entries. data-form-set-send="fetch" On the form: posts the values to the action without leaving the page, as FormData, with the header Accept: application/json. An answer in the 200s shows Sent, anything else Try again. It is the setting send, so it may also be set for the whole site. method="post" On the form: how the values are sent, POST when nothing is written. With GET and fetch they travel in the address. -
Drive it from JavaScript
When the page sends the values itself, when it fills or checks the form from a script, or when it reacts to the form being sent.const element = document.querySelector('[data-form-set]'); element.addEventListener('formset:sent', (e) => { console.log(e.detail); }); // Send the values yourself: the button waits until done() is called. element.addEventListener('formset:submit', async (event) => { event.preventDefault(); const answer = await fetch('/api/contact', { method: 'POST', body: new FormData(event.detail.form) }); event.detail.done(answer.ok); // true shows Sent, false shows Try again }); const set = FormSet.mount(element); // the form auto() mounted, or a new one set.set('email', 'pam@dundermifflin.com'); // as if it had been typed set.validate(); // shows the reasons, returns true when the form can leave set.reset(); // empties it and takes every state off // When the form leaves the page: FormSet.destroy(element); // removes what the engine added, gives the markup back as it was
-
Fit it to your page
Sending the form
Once every field is right the form leaves, in one of four ways. The button shows
Sendingand a running bar meanwhile, thenSentwith a tick, orTry again.- In Webflow, as a native form. The paste is a real Webflow Form block: the Designer shows Form block, Form, the fields with their own settings, and the two message blocks. Nothing to connect. Webflow receives the entries itself: they are listed in Site settings, Forms, and on the site's Forms page, each field under the name of its label. The address that is told of a new entry, and the subject of that mail, are set in the same place, Site settings, Forms, Form notifications. Entries are only received on a published site, the webflow.io address included; the Designer and its preview send nothing. When Webflow has received the form it hides it and shows its Success message; when it could not, it shows its Error message under the form and the button says
Try again. Both messages are Webflow's own blocks, in the Navigator under the Form: select the Form block, open its settings and choose the state to see and restyle each one, and rewrite its words on the canvas. The engine knows a native form by its Form block, and reads which message Webflow shows. Do not give that form an Action in its settings: a Webflow form with an action is posted there instead, and Webflow receives nothing. - To an endpoint, without leaving the page. Give the form the address in its
actionand adddata-form-set-send="fetch". The values are posted withfetch, asFormData, with the form'smethod(POST when none is written) and the headerAccept: application/json, which is what Formspree, Basin, Getform and the like expect. An answer in the 200s showsSent; any other, or no answer, showsTry again. The endpoint has to accept requests from the page's origin. - The usual way. With an
actionand nothing else the browser posts the form and goes to the page the server answers with, as any form does. The states of the fields and the check before sending still play. - By hand. The form fires
formset:submitbefore it leaves, with{ form, done, reset }indetail. CallpreventDefault()on it, send the values yourself, thendetail.done(true)ordetail.done(false);detail.reset()empties the form to start again. This is how the preview works, and all its page adds to the two files: a listener ondocumentcallspreventDefault(), thendetail.done(true)after 1800 ms anddetail.reset()after 5200 ms. It sends nothing, holds the button, shows it sent, then empties the form. A page that must behave exactly as the preview carries that listener; a real page sends instead.
html <script> /* The preview sends nothing: it holds the form, shows it as sent, then empties it. */ document.addEventListener('formset:submit', function (event) { event.preventDefault(); setTimeout(function () { event.detail.done(true); }, 1800); setTimeout(function () { event.detail.reset(); }, 5200); }); </script>The snippet is written with
action="#", a placeholder that sends nothing: replace it with your address, or use one of the other ways. The Webflow paste leaves it out.Fields
- A field is one box marked
data-form-set-fieldthat holds onelabeland one control: aninputof type text, email, tel, number, url or password, aselect, or atextarea. The label points at the control withfor. - To add a field, copy the box of a field of the same kind and give the new control its own
id, the same value in theforof its label, and its ownname: the name is what the entry is filed under. In Webflow, duplicate the field block in the Navigator and rename the field in its settings. - To remove a field, delete its box. To reorder, move the boxes: the engine reads them in the order of the page, and nothing else depends on it.
- What makes a field wrong is what its control says:
required,type="email",type="tel",minlength,pattern. Add or take off the attribute to change the rule. In Webflow, Required is a switch in the field's settings, andminlengthandpatternare custom attributes on the field. A field is checked when the visitor leaves it, then again at every key while it is wrong, and every field is checked when the form is sent: the first wrong one takes the focus. - A line of help goes in an element marked
data-form-set-hintinside the box. The reason takes its place while the field is wrong. - The checkbox is a
labelmarkeddata-form-set-field="check"that holds theinputand its sentence in aspan. - A select opens a list of its own, see below. It shows its first option while nothing is chosen: leave that option's value empty, and its text too, since the label is the invitation.
- The textarea grows with its text. With a
maxlengthit shows a count, which darkens over the last tenth.
The list of a select
A select opens a list drawn by the engine in place of the system's: a panel under the control, one line per option, the chosen one ticked.
- **The native
selectstays in the page**, under the list, and is what the form sends: itsname, itsvalue, itsrequired. The list is only a picture of its options, hidden from screen readers, which read the select itself as they always do. Add, remove or reorder theoptionelements and the list follows at the next mount; an option with an empty value, the invitation, and a disabled one are left out of it. - With the mouse, a press on the control opens the list, a click on a line chooses it, a click anywhere else closes it.
- With the keyboard, Enter, Space or an arrow opens it; the arrows, Home and End move the highlighted line and the value moves with it; Enter, Space or Tab keep the value, Escape puts back the one it had.
- On a touch screen (
pointer: coarse) the list is not drawn: the system's own picker opens, which is the better one under a thumb. - Its colours are the form's variables: the panel is
--form-set-ink, its words--form-set-ground, the tick of the chosen line--form-set-accent. For more, the list is.form-set-listand a line.form-set-option, with[data-form-set-active]on the highlighted line and[data-form-set-selected]on the chosen one: restyle them in a rule of your own, after the stylesheet. A list longer than its height scrolls. - To keep the system's list everywhere, set
selecttonative:data-form-set-select="native"on the form, or on the box of one field for that select alone. It is the choice for a very long list, a country for one, where the system's search by typing is worth more than the look. - While the list is open the field carries
data-form-set-list="open".
The words of the errors
Each reason is a setting, so an attribute on the form:
data-form-set-required="Ce champ est obligatoire.", and the same foremail,tel,short,patternandcheck. The same attribute on one field's box changes the words for that field alone. The words of the button aresending,sentandfailed. For every form of the site at once, change the settings block at the top of the Embed, or declarewindow.FormSetSettings = { required: '...' }before the script.The colours
Five custom properties, set at zero specificity on the form. Give them your own values on the form or on anything above it, the section or
body:--form-set-ink: the text of the fields, the line of the focus, the tick.--form-set-ground: the ground the form sits on; the tick of the checkbox is drawn in it.--form-set-rule: the rule under a field at rest.--form-set-accent: the plate and the bar of the button.--form-set-error: a wrong field and its reason.
The look of the preview, the last part of the stylesheet and the classes of the Webflow paste, names its own colours: change them there too, in the Style panel or in that part of the file.
In a dialog
Any form of the set can open in a dialog, the way Form 03 does. Three attributes, read by the same engine:
data-form-set-dialogon a layer that holds a panel, the panel holding the form. The stylesheet lays the layer over the page and hides it until it is opened; the layer and the panel take their look from classes of your own. With no value the panel rises into place, and comes up from the bottom edge on a phone: centre it in the layer. With the valuesheet,data-form-set-dialog="sheet", it slides in from the right, one second in and a little less out, and what it holds comes in after it: give the layerdisplay: flexandjustify-content: flex-end, and the panel amax-widthandmin-height: 100%.data-form-set-openon what opens it, any button or link of the page. With several dialogs on a page, give each layer anidand write it as the value:data-form-set-open="quote".data-form-set-closeon what closes it, inside the layer; the valuecrossdraws a cross in place of its word.
Escape and a click outside the panel close it too. While it is open the page behind does not scroll, Tab stays inside, and the focus goes to the first field, then back to what opened the dialog. Once the form is sent the dialog stays for
closeAftermilliseconds, then closes. Give the layerrole="dialog",aria-modal="true"and a name,aria-labelledbypointing at its heading. The spec of Form 03 has the whole of it.From JavaScript
FormSet.mount(form, overrides)returns the instance of a form, mounting it if it was not:{ element, settings, fields(), set(name, value), enter(name), leave(name), validate(), submit(), done(ok), reset(), refresh(), list(name, shown), open(), close(), destroy() }.setwrites a value as if it had been typed;validateshows the reasons and returns whether the form can leave;listshows the drawn list of a select, or closes it withfalse;openandcloseare for the dialog the form sits in, and do nothing for a form on the page.FormSet.auto(root)mounts every marked form insideroot, and runs once on its own;FormSet.destroy(form)gives the markup back, the dialog's too once its last form is gone.- Events, all bubbling from the form:
formset:ready,formset:invalid(with the wrong controls indetail.fields),formset:submit(cancelable, see above),formset:sentandformset:failed. - The state is on each element as
data-form-set-state:up,focus,filled,valid,error,disabledon a field, several at once;loading,doneorfailedon the button;doneon the form;openon a dialog. - **Under
prefers-reduced-motion: reduce** every state still shows, at once: the transitions and the bar of the button are cut to nothing. - Without JavaScript the form is a plain form: labels above their fields, the browser's own checks, a button that posts it.
- The words of the demo are the demo's: on a real page every label is the user's own, in the page's language, or asked for.
- In Webflow, as a native form. The paste is a real Webflow Form block: the Designer shows Form block, Form, the fields with their own settings, and the two message blocks. Nothing to connect. Webflow receives the entries itself: they are listed in Site settings, Forms, and on the site's Forms page, each field under the name of its label. The address that is told of a new entry, and the subject of that mail, are set in the same place, Site settings, Forms, Form notifications. Entries are only received on a published site, the webflow.io address included; the Designer and its preview send nothing. When Webflow has received the form it hides it and shows its Success message; when it could not, it shows its Error message under the form and the button says
-
Avoid the pitfalls
- **
action="#"sends nothing.** It is the placeholder of the snippet. A form outside a Webflow Form block needs a real address,fetchor not, or a listener onformset:submit. - In Webflow, entries only arrive from a published site. The Designer preview plays the states when custom code is enabled, and sends nothing.
- In Webflow the send button is a Link block, marked
data-form-set-submit. A native Webflow Submit button is aninput, which cannot hold the plate, the label and the bar. The script makes the Link send the form, and Enter in a field does too. A native Submit button marked the same way still sends, with no animation on it. - One look of fields per page. The forms of the set share the engine, and each stylesheet draws the fields its own way: Form 01 and Form 02 on one page would mix their looks. Two forms of the same number on a page are fine.
- The paste carries two Embeds. The first holds the settings and what moves, the second the engine. Keep both, in that order.
- **Every control needs a
name**, or its value is not sent, and alabel. - **A control outside a box marked
data-form-set-field** is sent with the form but is not checked and has no state. - A dialog laid over the page needs nothing above it that moves. The layer is
position: fixed: inside an element with atransform, afilteror aperspectiveit is fixed to that element instead of the window. Keep it out of such a box, at the end of the section or of the page. - The browser's own bubbles are off: the engine sets
novalidateon the form while it is mounted, and gives it back on destroy. - The stylesheet draws the states. Without
form-01.cssthe fields are checked but nothing shows, and the engine says so in the console. - **With
fetch, a server that redirects to a thank-you page** answers that page instead of a status: use the endpoint's JSON mode, which theAcceptheader asks for. - The label floats over the control, so the control keeps no placeholder of its own: a placeholder would show under the label at rest.
- **
One click on Copy the AI prompt copies everything an assistant needs to build this effect: the complete code, the markup, the dependency, every setting and every pitfall.
What it does
Form 01 is an animated form for Webflow and vanilla JavaScript, with no library. Also called animated contact form, floating label form or underline input animation.
A contact form with underline fields. Each label rests in the line of its field like a placeholder and floats up to its place when the field takes the focus or a value; a line is drawn under the field from the left on focus and leaves by the right; a tick draws itself at the end of a field that is right; a wrong field turns its line and label red, and its reason takes the place of the hint under it, then leaves at the keystroke that fixes it, so nothing below ever moves. The textarea grows with its text and counts its characters, the select turns its chevron, the checkbox draws its tick. The fields are checked as the visitor leaves them, with what the controls already say: required, an email, a phone number, a minimum length, a pattern. The send button has a plate of colour that rises on hover, shakes its head once when a field is still wrong, runs a bar while the form leaves, then says Sent with a tick, or Try again. It is a real form: pasted in Webflow it is a native Form block whose entries land in the site's Forms tab; elsewhere it posts to its action, with fetch or the usual way, or hands the values to the page. Labels, reasons read out by a screen reader, keyboard throughout, reduced motion respected. No library. Form 01 of the Form set. Pattern type contact form, floating label, form validation.
Updated
Questions
Paste the form: it arrives as a native Webflow Form block whose Form carries data-form-set, each field sitting in a block marked data-form-set-field with its label and its control. The label rests in the line of the field and floats up when the field takes the focus or a value, and a line is drawn under the field from the left.
Yes. The paste is a real Form block with no action, so Webflow receives it: the entries are listed in Site settings, Forms, each field under the name of its label, and the notification address is set there. Entries only arrive from a published site, and the success and error messages are Webflow's own two blocks.
Yes. Outside a Webflow Form block the form posts to its action; with data-form-set-send="fetch" it posts there without leaving the page and the button shows Sent or Try again from the status of the answer. A listener on formset:submit can also send the values itself and call detail.done(true).
It reads what the control already says: required, type="email", type="tel", minlength and pattern. A field is checked when the visitor leaves it and again at every key while it is wrong; the reason replaces the hint under the field, and each one is a setting such as data-form-set-required or data-form-set-email on the form or on one field.
A native Webflow Submit button is an input, which cannot hold the plate that rises on hover, the label and the running bar. The paste uses a Link block marked data-form-set-submit, and the script makes it send the form; Enter in a field sends it too.