Actions
ButtonEmail
Copies the email to the clipboard with feedback
Preview
size
email
copiedText
children
Code
import css from "./ButtonEmail.module.css";
import { useRef, useState } from "react";
import type { Icon } from "@phosphor-icons/react";
type Size = "xs" | "sm" | "md";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface ButtonEmailProps {
size: Size | ResponsiveSize;
icons: { copy: Icon; loading: Icon };
email: string;
copiedText: string;
"aria-label"?: string;
children: React.ReactNode;
}
// MUST match the CSS spin (1s): the icon has to finish its turn.
const SPIN_MS = 1000;
const COPIED_MS = 2000;
const sleep = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function ButtonEmail({
size,
icons,
email,
copiedText,
"aria-label": ariaLabel,
children,
}: ButtonEmailProps) {
const CopyIcon = icons.copy;
const LoadingIcon = icons.loading;
const accessibleName = ariaLabel ? `${ariaLabel}: ${email}` : undefined;
const button = useRef<HTMLButtonElement>(null);
const [width, setWidth] = useState<number>();
const [loading, setLoading] = useState(false);
const [active, setActive] = useState(false);
const [disabled, setDisabled] = useState(false);
const onClick = async () => {
if (disabled) return;
setDisabled(true);
setWidth(button.current?.clientWidth);
// finally: a rejected clipboard write must not leave the button disabled
try {
await navigator.clipboard.writeText(email);
setLoading(true);
await sleep(SPIN_MS);
setLoading(false);
setActive(true);
await sleep(COPIED_MS);
setActive(false);
} finally {
setDisabled(false);
setWidth(undefined);
}
};
return (
<button
ref={button}
type="button"
className={`${css.button} ${getSizeClasses(size)}`}
style={{ width }}
aria-disabled={disabled || undefined}
aria-label={accessibleName}
onClick={onClick}
>
{active ? <span aria-hidden="true">{copiedText}</span> : children}
<span className={css.button_status} role="status">
{active ? copiedText : ""}
</span>
{loading && (
<div
className={`
${css.button_icon}
${css.loading}
`}
>
<LoadingIcon aria-hidden="true" />
</div>
)}
{!loading && !active && (
<div className={css.button_icon}>
<CopyIcon aria-hidden="true" />
</div>
)}
</button>
);
}
.button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
padding: 0.4em 0.7em;
background: none;
transition: background 0.2s ease;
border: none;
border-radius: var(--radius-8);
cursor: pointer;
font-family: var(--font-body);
font-weight: var(--weight-500);
color: var(--color-accent);
-webkit-tap-highlight-color: transparent;
/* Only the font-size steps: gap and padding are in em, so they follow it. */
&.xs {
font-size: var(--text-16);
}
&.sm {
font-size: var(--text-20);
}
&.md {
font-size: var(--text-24);
}
&.xs_base {
font-size: var(--text-16);
}
&.sm_base {
font-size: var(--text-20);
}
&.md_base {
font-size: var(--text-24);
}
@media (min-width: 480px) {
&.xs_mobile {
font-size: var(--text-16);
}
&.sm_mobile {
font-size: var(--text-20);
}
&.md_mobile {
font-size: var(--text-24);
}
}
@media (min-width: 768px) {
&.xs_tablet {
font-size: var(--text-16);
}
&.sm_tablet {
font-size: var(--text-20);
}
&.md_tablet {
font-size: var(--text-24);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
font-size: var(--text-16);
}
&.sm_desktop {
font-size: var(--text-20);
}
&.md_desktop {
font-size: var(--text-24);
}
}
@media (min-width: 1440px) {
&.xs_wide {
font-size: var(--text-16);
}
&.sm_wide {
font-size: var(--text-20);
}
&.md_wide {
font-size: var(--text-24);
}
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
@media (hover: hover) {
&:hover {
background: var(--color-surface);
}
}
/* Keeps the hover fill through the copy cycle: on touch the button would dissolve into plain
text. */
&[aria-disabled="true"] {
background: var(--color-surface);
}
&:active {
background: color-mix(in srgb, var(--color-surface), var(--color-text) 8%);
}
.button_icon {
display: flex;
font-size: inherit;
&.loading {
animation: spin 1s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
.button_status {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
<script setup lang="ts">
import { computed, ref, useTemplateRef } from "vue";
import type { Component } from "vue";
type Size = "xs" | "sm" | "md";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface ButtonEmailProps {
size: Size | ResponsiveSize;
icons: { copy: Component; loading: Component };
email: string;
copiedText: string;
ariaLabel?: string;
}
// MUST match the CSS spin (1s): the icon has to finish its turn.
const SPIN_MS = 1000;
const COPIED_MS = 2000;
const sleep = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const { size, email, ariaLabel } = defineProps<ButtonEmailProps>();
const button = useTemplateRef<HTMLButtonElement>("button");
const width = ref<number>();
const loading = ref(false);
const active = ref(false);
const disabled = ref(false);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
// Vue does not add the unit to numbers in :style the way React does.
const widthStyle = computed(() =>
width.value === undefined ? undefined : `${width.value}px`,
);
const accessibleName = computed(() => (ariaLabel ? `${ariaLabel}: ${email}` : undefined));
const onClick = async () => {
if (disabled.value) return;
disabled.value = true;
width.value = button.value?.clientWidth;
// finally: a rejected clipboard write must not leave the button disabled
try {
await navigator.clipboard.writeText(email);
loading.value = true;
await sleep(SPIN_MS);
loading.value = false;
active.value = true;
await sleep(COPIED_MS);
active.value = false;
} finally {
disabled.value = false;
width.value = undefined;
}
};
</script>
<template>
<button
ref="button"
type="button"
class="button"
:class="sizeClasses"
:style="{ width: widthStyle }"
:aria-disabled="disabled || undefined"
:aria-label="accessibleName"
@click="onClick"
>
<span v-if="active" aria-hidden="true">{{ copiedText }}</span>
<slot v-else />
<span class="button_status" role="status">{{ active ? copiedText : "" }}</span>
<div v-if="loading" class="button_icon loading">
<component :is="icons.loading" aria-hidden="true" />
</div>
<div v-if="!loading && !active" class="button_icon">
<component :is="icons.copy" aria-hidden="true" />
</div>
</button>
</template>
<style scoped>
.button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
padding: 0.4em 0.7em;
background: none;
transition: background 0.2s ease;
border: none;
border-radius: var(--radius-8);
cursor: pointer;
font-family: var(--font-body);
font-weight: var(--weight-500);
color: var(--color-accent);
-webkit-tap-highlight-color: transparent;
/* Only the font-size steps: gap and padding are in em, so they follow it. */
&.xs {
font-size: var(--text-16);
}
&.sm {
font-size: var(--text-20);
}
&.md {
font-size: var(--text-24);
}
&.xs_base {
font-size: var(--text-16);
}
&.sm_base {
font-size: var(--text-20);
}
&.md_base {
font-size: var(--text-24);
}
@media (min-width: 480px) {
&.xs_mobile {
font-size: var(--text-16);
}
&.sm_mobile {
font-size: var(--text-20);
}
&.md_mobile {
font-size: var(--text-24);
}
}
@media (min-width: 768px) {
&.xs_tablet {
font-size: var(--text-16);
}
&.sm_tablet {
font-size: var(--text-20);
}
&.md_tablet {
font-size: var(--text-24);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
font-size: var(--text-16);
}
&.sm_desktop {
font-size: var(--text-20);
}
&.md_desktop {
font-size: var(--text-24);
}
}
@media (min-width: 1440px) {
&.xs_wide {
font-size: var(--text-16);
}
&.sm_wide {
font-size: var(--text-20);
}
&.md_wide {
font-size: var(--text-24);
}
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
@media (hover: hover) {
&:hover {
background: var(--color-surface);
}
}
/* Keeps the hover fill through the copy cycle: on touch the button would dissolve into plain
text. */
&[aria-disabled="true"] {
background: var(--color-surface);
}
&:active {
background: color-mix(in srgb, var(--color-surface), var(--color-text) 8%);
}
.button_icon {
display: flex;
font-size: inherit;
&.loading {
animation: spin 1s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
.button_status {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | ResponsiveSize | required | Button size (font-size; gap and padding scale in em). Fixed or responsive. |
| icons | { copy: Icon; loading: Icon } | required | Idle and loading icons. The component imports neither. |
string | required | Address copied to the clipboard. | |
| copiedText | string | required | Confirmation text that replaces the content after copying. |
| aria-label | string | — | Action placed before the email in the accessible name (e.g. "Copy the email to the clipboard: hello@…"). Only when the visible text is the email. |
| children | React.ReactNode | required | Content shown when idle. If it is not the email itself, leave out aria-label. |