Sección de contacto editorial: un formulario junto a canales de contacto numerados (correo, teléfono, oficina, horario con estado abierto o cerrado en vivo), un formulario corto centrado, o una cuadrícula de oficinas con la hora local sobre un formulario compacto; se envía de forma nativa sin JavaScript, envía en segundo plano con estados de envío, éxito y error anunciados cuando <mv-contact> está presente, y descarta el spam detectado por un honeypot.
Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["contact"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<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
Name
Tipo
Default
Description
data-variant
split | centered | offices
split
On .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-timezone
IANA time zone
On .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-hours
HH:MM-HH:MM
On .mv-contact-clock: opening hours in that time zone; adds an Open now / Closed state and toggles data-open.
data-days
string
1-5
On .mv-contact-clock: open weekdays as a range or list (0 or 7 = Sunday), e.g. "1-6" or "1,3,5".
action / method / enctype
native
On .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-state
sending | sent | error
Set on the form by <mv-contact> while sending and after the answer, for your own styling.
aria-busy
true
Set on the form and its submit button while sending: the button shows its spinner, fields dim and ignore the pointer.
Properties
Name
Tipo
Description
strings
object
Override 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.
pending
HTMLFormElement | null
Form currently being sent (read-only).
Methods
Name
Description
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
Name
Description
mv-submit
A 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-success
The message was sent. detail: { form }.
mv-error
Sending failed. detail: { form, message }.
Content structure
Name
Description
.mv-contact-header
-eyebrow (accent square), -title (<em> dims a phrase), -lead.
ul.mv-contact-channels
Split 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-form
Panel 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-trap
Honeypot: 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-status
role="status" aria-live="polite", hidden when empty; data-tone="pending | success | error" styles it.
Centered variant: fallback line under the form; <span> items are separated by a middle dot.
CSS classes
Name
Description
mv-contact
Root section, mv-contact query container.
mv-contact-inner
Width, padding and numbering scope.
mv-contact-clock
Live local time; -state (Open now / Closed with a dot) and -time are generated.
CSS variables
Name
Default
Description
--mv-contact-max
72rem
Max width.
--mv-contact-bg
transparent
Section background.
--mv-contact-panel-bg
var(--mv-surface)
Form panel background.
--mv-contact-offices-columns
4
Office 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.