Exclusive

Contact <mv-contact>

Editorial contact section: a form beside numbered contact channels (email, phone, office, hours with a live open or closed state), a short centered form, or a grid of offices with local time above a compact form; posts natively without JavaScript, sends in the background with announced sending, success and error states when <mv-contact> is present, and drops spam caught by a honeypot.

CategoryBlocks
TypeBlock (ready-made section) (<mv-contact>)
Statusstable
Also installsbutton, checkbox, field, input, select, textarea
Keywordsexclusive, contact, contact form, form, support, sales, offices, locations, honeypot, landing

When to use

  • A marketing site needs a contact page with a form next to email, phone, office address and opening hours
  • A support or sales team wants a short centered request form with an email fallback
  • A company with offices in several cities should list them with local time above one routed form
  • The form must keep working without JavaScript and report sending, success and errors to screen readers

Avoid when

  • Visitors only leave an email address to join a newsletter or a waitlist → use CTA instead
  • The form signs people in, creates an account or resets a password → use Auth instead
  • The page mostly answers common questions and a contact link is enough → use FAQ instead

Install

node scripts/add.mjs contact --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["contact"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, components/button/button.css, components/checkbox/checkbox.css, components/field/field.css, components/input/input.css, components/select/select.css, core/dom.js, core/element.js, components/textarea/textarea.css, components/textarea/char-count.js, components/contact/contact.css, components/contact/contact.js.

Usage

Canonical markup, to start from and customize with attributes, data-* and CSS variables:

<mv-tabs id="contact-demo" value="split" data-size="sm" style="gap:0;background:var(--mv-bg)">
  <style>#contact-demo > [role="tablist"] { justify-self: center; margin: .75rem auto 0; }</style>
  <div role="tablist" aria-label="Block variant">
    <button role="tab" data-value="split">Split</button>
    <button role="tab" data-value="centered">Centered</button>
    <button role="tab" data-value="offices">Offices</button>
  </div>

  <!-- ═════════ Split (default) ═════════ -->
  <div role="tabpanel" data-value="split">
    <mv-contact>
      <section class="mv-contact" aria-labelledby="ct-s-title">
        <div class="mv-contact-inner">
          <div class="mv-contact-aside">
            <header class="mv-contact-header">
              <p class="mv-contact-eyebrow">Contact</p>
              <h2 class="mv-contact-title" id="ct-s-title">Talk to the people <em>who build it.</em></h2>
              <p class="mv-contact-lead">Questions about pricing, a rollout for 400 technicians or a feature you need? A product specialist answers within one business day.</p>
            </header>
            <ul class="mv-contact-channels" aria-label="Other ways to reach us">
              <li>
                <a class="mv-contact-channel" href="mailto:[email protected]">
                  <small>Sales</small>
                  <strong>[email protected]</strong>
                  <span>Quotes, volume pricing and procurement.</span>
                </a>
              </li>
              <li>
                <a class="mv-contact-channel" href="tel:+14155550142">
                  <small>Phone</small>
                  <strong>+1 (415) 555-0142</strong>
                  <span>Ask for Priya or Mateo on the sales desk.</span>
                </a>
              </li>
              <li>
                <a class="mv-contact-channel" href="https://www.openstreetmap.org/search?query=548%20Market%20Street%20San%20Francisco" target="_blank" rel="noopener">
                  <small>Office</small>
                  <strong>548 Market Street, Suite 300</strong>
                  <span>San Francisco, CA 94104 · Open in maps</span>
                </a>
              </li>
              <li>
                <div class="mv-contact-channel">
                  <small>Hours</small>
                  <strong>Mon to Fri, 8 am to 6 pm</strong>
                  <span>Pacific Time (San Francisco)</span>
                  <span class="mv-contact-clock" data-timezone="America/Los_Angeles" data-hours="08:00-18:00"></span>
                </div>
              </li>
            </ul>
          </div>

          <form class="mv-contact-form" id="ct-s-form" action="/api/contact" method="post" aria-labelledby="ct-s-form-title">
            <div class="mv-contact-form-head">
              <h3 class="mv-contact-form-title" id="ct-s-form-title">Send a message</h3>
              <p class="mv-contact-form-meta">Median reply · 3 h 20 min</p>
            </div>
            <div class="mv-field-group">
              <div class="mv-field-row">
                <div class="mv-field">
                  <label class="mv-label" for="ct-s-name" data-required>Full name</label>
                  <input class="mv-input" id="ct-s-name" name="name" autocomplete="name" required aria-describedby="ct-s-name-err">
                  <p class="mv-field-error" id="ct-s-name-err">Enter your name so we know who to reply to.</p>
                </div>
                <div class="mv-field">
                  <label class="mv-label" for="ct-s-email" data-required>Work email</label>
                  <input class="mv-input" id="ct-s-email" name="email" type="email" autocomplete="email" placeholder="[email protected]" required aria-describedby="ct-s-email-err">
                  <p class="mv-field-error" id="ct-s-email-err">Enter a full address, like [email protected].</p>
                </div>
              </div>
              <div class="mv-field-row">
                <div class="mv-field">
                  <label class="mv-label" for="ct-s-company" data-optional="Optional">Company</label>
                  <input class="mv-input" id="ct-s-company" name="company" autocomplete="organization">
                </div>
                <div class="mv-field">
                  <label class="mv-label" for="ct-s-topic">Topic</label>
                  <select class="mv-select" id="ct-s-topic" name="topic">
                    <option value="sales">Pricing and plans</option>
                    <option value="demo">Book a product demo</option>
                    <option value="partners">Partnerships</option>
                    <option value="press">Press and media</option>
                    <option value="other">Something else</option>
                  </select>
                </div>
              </div>
              <div class="mv-field">
                <div class="mv-field-header">
                  <label class="mv-label" for="ct-s-message" data-required>Message</label>
                  <mv-char-count for="ct-s-message"></mv-char-count>
                </div>
                <textarea class="mv-textarea" id="ct-s-message" name="message" minlength="20" maxlength="2000" required placeholder="Team size, the tools you use today, what you’d like to change…" aria-describedby="ct-s-message-err"></textarea>
                <p class="mv-field-error" id="ct-s-message-err">Tell us a little more: at least 20 characters.</p>
              </div>
              <div class="mv-field">
                <label class="mv-choice">
                  <input class="mv-checkbox" type="checkbox" name="consent" required aria-describedby="ct-s-consent-err">
                  <span>I agree to the <a href="#contact-demo">privacy policy</a> and to being contacted about this request.</span>
                </label>
                <p class="mv-field-error" id="ct-s-consent-err">Please accept so we can reply to you.</p>
              </div>
            </div>
            <div class="mv-contact-trap" aria-hidden="true">
              <label for="ct-s-website">Leave this field empty</label>
              <input id="ct-s-website" name="website" tabindex="-1" autocomplete="off">
            </div>
            <div class="mv-contact-actions">
              <button class="mv-button" type="submit">Send message</button>
              <p class="mv-contact-fine">No newsletter, no sales sequence. One person reads it and replies.</p>
            </div>
            <p class="mv-contact-status" role="status" aria-live="polite"></p>
          </form>
        </div>
      </section>
    </mv-contact>
  </div>

  <!-- ═════════ Centered ═════════ -->
  <div role="tabpanel" data-value="centered">
    <mv-contact>
      <section class="mv-contact" data-variant="centered" aria-labelledby="ct-c-title">
        <div class="mv-contact-inner">
          <header class="mv-contact-header">
            <p class="mv-contact-eyebrow">Support</p>
            <h2 class="mv-contact-title" id="ct-c-title">How can we help?</h2>
            <p class="mv-contact-lead">Describe what you’re trying to do. A support engineer in your time zone picks it up, usually within two hours.</p>
          </header>
          <form class="mv-contact-form" id="ct-c-form" action="/api/support" method="post" aria-label="Support request">
            <div class="mv-field-group">
              <div class="mv-field-row">
                <div class="mv-field">
                  <label class="mv-label" for="ct-c-name" data-required>Name</label>
                  <input class="mv-input" id="ct-c-name" name="name" autocomplete="name" required aria-describedby="ct-c-name-err">
                  <p class="mv-field-error" id="ct-c-name-err">Enter your name.</p>
                </div>
                <div class="mv-field">
                  <label class="mv-label" for="ct-c-email" data-required>Email</label>
                  <input class="mv-input" id="ct-c-email" name="email" type="email" autocomplete="email" placeholder="[email protected]" required aria-describedby="ct-c-email-err">
                  <p class="mv-field-error" id="ct-c-email-err">Enter a full address, like [email protected].</p>
                </div>
              </div>
              <div class="mv-field">
                <label class="mv-label" for="ct-c-message" data-required>What’s going on?</label>
                <textarea class="mv-textarea" id="ct-c-message" name="message" minlength="10" maxlength="2000" required placeholder="Steps to reproduce, the page you were on, what you expected…" aria-describedby="ct-c-message-err" style="--mv-textarea-min-rows:3"></textarea>
                <p class="mv-field-error" id="ct-c-message-err">Add a few words: at least 10 characters.</p>
              </div>
            </div>
            <div class="mv-contact-trap" aria-hidden="true">
              <label for="ct-c-website">Leave this field empty</label>
              <input id="ct-c-website" name="website" tabindex="-1" autocomplete="off">
            </div>
            <div class="mv-contact-actions">
              <button class="mv-button" type="submit">Send request</button>
              <p class="mv-contact-fine">By sending, you agree to our <a href="#contact-demo">privacy policy</a>.</p>
            </div>
            <p class="mv-contact-status" role="status" aria-live="polite"></p>
          </form>
          <p class="mv-contact-alt">
            <span>Prefer email? <a href="mailto:[email protected]">[email protected]</a></span>
            <span><a href="#contact-demo">Status page</a></span>
          </p>
        </div>
      </section>
    </mv-contact>
  </div>

  <!-- ═════════ Offices ═════════ -->
  <div role="tabpanel" data-value="offices">
    <mv-contact>
      <section class="mv-contact" data-variant="offices" aria-labelledby="ct-o-title">
        <div class="mv-contact-inner">
          <header class="mv-contact-header">
            <p class="mv-contact-eyebrow">Offices</p>
            <h2 class="mv-contact-title" id="ct-o-title">Four offices, <em>one team.</em></h2>
            <p class="mv-contact-lead">Visit us, call the nearest desk, or write below and the right office replies in your language.</p>
          </header>
          <ul class="mv-contact-offices" aria-label="Our offices">
            <li class="mv-contact-office">
              <p class="mv-contact-office-label">Headquarters</p>
              <h3 class="mv-contact-office-city">San Francisco</h3>
              <address>548 Market Street, Suite 300<br>San Francisco, CA 94104<br>United States</address>
              <ul class="mv-contact-office-links">
                <li><a href="tel:+14155550142">+1 (415) 555-0142</a></li>
                <li><a href="https://www.openstreetmap.org/search?query=548%20Market%20Street%20San%20Francisco" target="_blank" rel="noopener">Directions</a></li>
              </ul>
              <span class="mv-contact-clock" data-timezone="America/Los_Angeles" data-hours="08:00-18:00"></span>
            </li>
            <li class="mv-contact-office">
              <p class="mv-contact-office-label">Europe</p>
              <h3 class="mv-contact-office-city">Lisbon</h3>
              <address>Rua do Alecrim 47, 3rd floor<br>1200-018 Lisbon<br>Portugal</address>
              <ul class="mv-contact-office-links">
                <li><a href="tel:+351215550192">+351 21 555 0192</a></li>
                <li><a href="https://www.openstreetmap.org/search?query=Rua%20do%20Alecrim%2047%20Lisboa" target="_blank" rel="noopener">Directions</a></li>
              </ul>
              <span class="mv-contact-clock" data-timezone="Europe/Lisbon" data-hours="09:00-18:00"></span>
            </li>
            <li class="mv-contact-office">
              <p class="mv-contact-office-label">Asia Pacific</p>
              <h3 class="mv-contact-office-city">Singapore</h3>
              <address>71 Robinson Road, #14-01<br>Singapore 068895</address>
              <ul class="mv-contact-office-links">
                <li><a href="tel:+6565550147">+65 6555 0147</a></li>
                <li><a href="https://www.openstreetmap.org/search?query=71%20Robinson%20Road%20Singapore" target="_blank" rel="noopener">Directions</a></li>
              </ul>
              <span class="mv-contact-clock" data-timezone="Asia/Singapore" data-hours="09:00-18:00"></span>
            </li>
            <li class="mv-contact-office">
              <p class="mv-contact-office-label">Latin America</p>
              <h3 class="mv-contact-office-city">São Paulo</h3>
              <address>Av. Paulista 1842, 12th floor<br>São Paulo, SP 01310-200<br>Brazil</address>
              <ul class="mv-contact-office-links">
                <li><a href="tel:+551155550133">+55 11 5555 0133</a></li>
                <li><a href="https://www.openstreetmap.org/search?query=Avenida%20Paulista%201842%20Sao%20Paulo" target="_blank" rel="noopener">Directions</a></li>
              </ul>
              <span class="mv-contact-clock" data-timezone="America/Sao_Paulo" data-hours="09:00-18:00"></span>
            </li>
          </ul>

          <form class="mv-contact-form" id="ct-o-form" action="/api/contact" method="post" aria-labelledby="ct-o-form-title">
            <div class="mv-contact-form-intro">
              <h3 id="ct-o-form-title">Write to the nearest team</h3>
              <p>Pick an office and we’ll route your message there. Replies come from a real person, in English, Portuguese or Mandarin.</p>
            </div>
            <div class="mv-field-group">
              <div class="mv-field-row">
                <div class="mv-field">
                  <label class="mv-label" for="ct-o-name" data-required>Name</label>
                  <input class="mv-input" id="ct-o-name" name="name" autocomplete="name" required aria-describedby="ct-o-name-err">
                  <p class="mv-field-error" id="ct-o-name-err">Enter your name.</p>
                </div>
                <div class="mv-field">
                  <label class="mv-label" for="ct-o-email" data-required>Email</label>
                  <input class="mv-input" id="ct-o-email" name="email" type="email" autocomplete="email" placeholder="[email protected]" required aria-describedby="ct-o-email-err">
                  <p class="mv-field-error" id="ct-o-email-err">Enter a full address.</p>
                </div>
                <div class="mv-field">
                  <label class="mv-label" for="ct-o-office">Office</label>
                  <select class="mv-select" id="ct-o-office" name="office">
                    <option value="sfo">San Francisco</option>
                    <option value="lis">Lisbon</option>
                    <option value="sin">Singapore</option>
                    <option value="sao">São Paulo</option>
                  </select>
                </div>
              </div>
              <div class="mv-field">
                <label class="mv-label" for="ct-o-message" data-required>Message</label>
                <textarea class="mv-textarea" id="ct-o-message" name="message" minlength="10" maxlength="2000" required placeholder="What can we help you with?" aria-describedby="ct-o-message-err" style="--mv-textarea-min-rows:3"></textarea>
                <p class="mv-field-error" id="ct-o-message-err">Add a few words: at least 10 characters.</p>
              </div>
            </div>
            <div class="mv-contact-trap" aria-hidden="true">
              <label for="ct-o-website">Leave this field empty</label>
              <input id="ct-o-website" name="website" tabindex="-1" autocomplete="off">
            </div>
            <div class="mv-contact-actions">
              <div class="mv-field" style="flex:1 1 16rem">
                <label class="mv-choice">
                  <input class="mv-checkbox" type="checkbox" name="consent" required aria-describedby="ct-o-consent-err">
                  <span>I agree to the <a href="#contact-demo">privacy policy</a>.</span>
                </label>
                <p class="mv-field-error" id="ct-o-consent-err">Please accept so we can reply.</p>
              </div>
              <button class="mv-button" type="submit">Send message</button>
            </div>
            <p class="mv-contact-status" role="status" aria-live="polite"></p>
          </form>
        </div>
      </section>
    </mv-contact>
  </div>

  <script type="module">
    // Demo only: nothing is sent. An address at example.com shows the error state.
    const demo = document.querySelector("#contact-demo");
    demo?.addEventListener("mv-submit", (e) => {
      e.preventDefault();
      const contact = e.target;
      const failing = /@example\.com$/i.test(e.detail.data.email ?? "");
      setTimeout(() => failing
        ? contact.fail("Our mail server didn’t answer. Your message is still here: try again in a minute.")
        : contact.done(`Thanks, ${String(e.detail.data.name).split(" ")[0]}. Your message is on its way, and we’ll reply by email.`), 1400);
    });
  </script>
</mv-tabs>

API

Attributes

NameTypeDefaultDescription
data-variantsplit | centered | officessplitOn .mv-contact. split (default, or no attribute): .mv-contact-aside (header + channels) beside the form from 56rem; centered: header, a form up to 36rem wide and .mv-contact-alt on one axis; offices: header with title and lead side by side from 48rem, .mv-contact-offices grid (1, 2, then --mv-contact-offices-columns), then a compact form with its intro on the left.
data-timezoneIANA time zoneOn .mv-contact-clock: shows the local time there (Intl, nearest lang), refreshed every minute. Without JavaScript the element stays empty and hidden, or keeps any fallback text you put in it.
data-hoursHH:MM-HH:MMOn .mv-contact-clock: opening hours in that time zone; adds an Open now / Closed state and toggles data-open.
data-daysstring1-5On .mv-contact-clock: open weekdays as a range or list (0 or 7 = Sunday), e.g. "1-6" or "1,3,5".
action / method / enctypenativeOn .mv-contact-form: where the form posts. Without JavaScript it submits normally; with <mv-contact> the same request is sent with fetch (urlencoded unless enctype is multipart/form-data, Accept: application/json). No action: nothing is sent and the form resolves as sent, so handle mv-submit.
data-statesending | sent | errorSet on the form by <mv-contact> while sending and after the answer, for your own styling.
aria-busytrueSet on the form and its submit button while sending: the button shows its spinner, fields dim and ignore the pointer.

Properties

NameTypeDescription
stringsobjectOverride any default text: sending, success, error, offline, open, closed, time ({time}). Clock times follow the nearest lang (Intl). Can be set before the element is defined.
pendingHTMLFormElement | nullForm currently being sent (read-only).

Methods

NameDescription
done(message?)Resolves the pending send: resets the form and announces message (or strings.success).
fail(message?)Rejects the pending send: keeps what was typed and announces message (or strings.error).
reset(form?)Clears the status of every form, or of one form.

Events

NameDescription
mv-submitA valid form is about to be sent. detail: { data, formData, form }. Cancelable: preventDefault() and send it yourself, then call done() or fail(). Not fired when the honeypot is filled.
mv-successThe message was sent. detail: { form }.
mv-errorSending failed. detail: { form, message }.

Content structure

NameDescription
.mv-contact-header-eyebrow (accent square), -title (<em> dims a phrase), -lead.
ul.mv-contact-channelsSplit variant: li > a.mv-contact-channel (mailto:, tel:, map link) or div.mv-contact-channel with <small> label, <strong> value, <span> note, optional .mv-contact-clock. Rows are numbered 01, 02… in a mono gutter; links get an arrow (↗ for http links).
form.mv-contact-formPanel form: optional .mv-contact-form-head (-title, -meta) or .mv-contact-form-intro (offices), a .mv-field-group of field, input, select, textarea and checkbox components, .mv-contact-trap, .mv-contact-actions (button + .mv-contact-fine), .mv-contact-status.
.mv-contact-trapHoneypot: a label and an input (tabindex="-1", autocomplete="off") moved off screen and aria-hidden. A filled trap is never sent by <mv-contact>; check it on the server too.
.mv-contact-statusrole="status" aria-live="polite", hidden when empty; data-tone="pending | success | error" styles it.
ul.mv-contact-offices > li.mv-contact-officeOffices variant: .mv-contact-office-label (numbered), h3.mv-contact-office-city, <address>, ul.mv-contact-office-links, .mv-contact-clock.
.mv-contact-altCentered variant: fallback line under the form; <span> items are separated by a middle dot.

CSS classes

NameDescription
mv-contactRoot section, mv-contact query container.
mv-contact-innerWidth, padding and numbering scope.
mv-contact-clockLive local time; -state (Open now / Closed with a dot) and -time are generated.

CSS variables

NameDefaultDescription
--mv-contact-max72remMax width.
--mv-contact-bgtransparentSection background.
--mv-contact-panel-bgvar(--mv-surface)Form panel background.
--mv-contact-offices-columns4Office columns from 60rem.

Accessibility

The section is labeled by its h2 and the form by its own heading or aria-label. Fields use native validation: errors appear under each field after interaction (:user-invalid) and are tied with aria-describedby; required fields carry required, the asterisk is decorative. Sending, success and error messages are spelled out in a polite live region, the submit button gets aria-busy and keeps focus. The honeypot is off screen, aria-hidden and out of the tab order. Channels are real mailto:, tel: and map links with visible text; external links open with rel="noopener". Row numbers are CSS-generated with empty alternative text. Focus rings stay visible in forced colors, and the pending pulse stops with reduced motion.