Botão origem
Botão inundado por um círculo que nasce no ponto exato do toque, do cursor ou no centro quando o foco vem do teclado.
usa o BTN-01
O código
/* ===== botao-origem.css ============================================================ */
/* BTN-01 · Botão origem — HTML/CSS/JS puro, sem React nem Motion.
O círculo nasce no ponto do toque ou do cursor (no centro, quando o foco vem do teclado) e
cobre o botão inteiro. Diâmetro = o dobro da distância até o canto mais longe, medido no JS.
Tokens: redeclare no seu próprio escopo. Os valores abaixo são o padrão da biblioteca (tema preto).
Diferença consciente em relação à variante que rodou no trento: ali o CTA tem degradê fixo e o
texto não troca de cor; aqui o texto troca, como no original do 21st.dev. */
.c-botao-origem {
--c-btn-fundo: #141414;
--c-btn-tinta: #ebebeb;
--c-btn-circulo: #ebebeb;
--c-btn-tinta-sobre-circulo: #0a0a0a;
--c-btn-linha: rgba(235, 235, 235, .14);
--c-btn-raio: 12px;
--c-btn-altura: 48px;
--c-btn-ease: cubic-bezier(.16, 1, .3, 1);
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--c-btn-altura);
padding: 0 32px;
border: .5px solid var(--c-btn-linha);
border-radius: var(--c-btn-raio);
overflow: hidden;
background: var(--c-btn-fundo);
color: var(--c-btn-tinta);
font: 500 15px/1 inherit;
letter-spacing: -.02em;
text-decoration: none;
cursor: pointer;
touch-action: manipulation; /* tira o atraso do toque duplo no celular */
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: transform .15s ease, color .3s var(--c-btn-ease);
}
.c-botao-origem:active { transform: scale(.985); }
.c-botao-origem[disabled], .c-botao-origem[aria-disabled="true"] { opacity: .5; pointer-events: none; }
.c-botao-origem.is-cheio { color: var(--c-btn-tinta-sobre-circulo); }
/* o círculo: z-index -1 com isolation no pai, para não vazar para fora da pilha do botão */
.c-botao-origem__circulo {
position: absolute;
z-index: -1;
left: var(--x, 50%);
top: var(--y, 50%);
width: var(--d, 0px);
height: var(--d, 0px);
border-radius: 50%;
background: var(--c-btn-circulo);
transform: translate(-50%, -50%) scale(0);
transition: transform .5s var(--c-btn-ease);
pointer-events: none;
}
.c-botao-origem.is-cheio .c-botao-origem__circulo { transform: translate(-50%, -50%) scale(1); }
.c-botao-origem__rotulo { position: relative; }
/* variante preenchida: o inverso — fundo claro, círculo escuro */
.c-botao-origem--solido {
--c-btn-fundo: #ebebeb;
--c-btn-tinta: #0a0a0a;
--c-btn-circulo: #0a0a0a;
--c-btn-tinta-sobre-circulo: #ebebeb;
--c-btn-linha: transparent;
}
/* variante de destaque: usa o âmbar como fundo */
.c-botao-origem--destaque {
--c-btn-fundo: #e8ad32;
--c-btn-tinta: #171717;
--c-btn-circulo: #171717;
--c-btn-tinta-sobre-circulo: #e8ad32;
--c-btn-linha: transparent;
}
@media (prefers-reduced-motion: reduce) {
.c-botao-origem, .c-botao-origem__circulo { transition: none; }
}
/* ===== botao-origem.js ============================================================= */
/* BTN-01 · Botão origem — o círculo nasce no ponto do toque (ou do cursor) e cobre o botão inteiro.
Reescrito do original do 21st.dev sem React nem Motion.
Também impede que link sem destino (href="#") role a página para o topo. */
(function () {
'use strict';
var TOQUE_MINIMO = 320; // ms: um toque rápido ainda deixa ver o círculo crescer
function diametro(w, h, x, y) {
return Math.ceil(2 * Math.max(Math.hypot(x, y), Math.hypot(w - x, y),
Math.hypot(x, h - y), Math.hypot(w - x, h - y)));
}
function origem(botao, x, y) {
var r = botao.getBoundingClientRect();
botao.style.setProperty('--x', x + 'px');
botao.style.setProperty('--y', y + 'px');
botao.style.setProperty('--d', diametro(r.width, r.height, x, y) + 'px');
}
function preparar(botao) {
if (botao.__cBtnOrigem) { return; }
botao.__cBtnOrigem = true;
if (!botao.querySelector('.c-botao-origem__circulo')) {
var c = document.createElement('span');
c.className = 'c-botao-origem__circulo';
c.setAttribute('aria-hidden', 'true');
botao.insertBefore(c, botao.firstChild);
}
var desde = 0;
var timer = null;
function encher(x, y) {
if (timer) { clearTimeout(timer); timer = null; }
origem(botao, x, y);
desde = Date.now();
botao.classList.add('is-cheio');
}
function esvaziar(imediato) {
var falta = imediato ? 0 : Math.max(0, TOQUE_MINIMO - (Date.now() - desde));
if (timer) { clearTimeout(timer); }
timer = setTimeout(function () { botao.classList.remove('is-cheio'); timer = null; }, falta);
}
function doPonteiro(e) {
var r = botao.getBoundingClientRect();
encher(e.clientX - r.left, e.clientY - r.top);
}
function doCentro() {
var r = botao.getBoundingClientRect();
encher(r.width / 2, r.height / 2);
}
botao.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { doPonteiro(e); } });
botao.addEventListener('pointerdown', function (e) { if (e.button === 0) { doPonteiro(e); } });
botao.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') { esvaziar(false); } });
botao.addEventListener('pointerleave', function (e) { esvaziar(e.pointerType === 'mouse'); });
botao.addEventListener('pointercancel', function () { esvaziar(true); });
botao.addEventListener('focus', function () { if (botao.matches(':focus-visible')) { doCentro(); } });
botao.addEventListener('blur', function () { esvaziar(true); });
botao.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { doCentro(); } });
botao.addEventListener('keyup', function (e) {
if ((e.key === 'Enter' || e.key === ' ') && !botao.matches(':focus-visible')) { esvaziar(false); }
});
}
function iniciar() {
Array.prototype.forEach.call(document.querySelectorAll('.c-botao-origem'), preparar);
document.addEventListener('click', function (e) {
var a = e.target.closest ? e.target.closest('a[href="#"]') : null;
if (a) { e.preventDefault(); }
});
}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); } else { iniciar(); }
})();
/* ===== markup.html ================================================================= */
<!-- BTN-01 · Botão origem. O <span class="c-botao-origem__circulo"> é criado pelo JS se faltar;
está aqui para o botão nascer completo mesmo antes do script rodar.
Use <button> para ação e <a> para navegação — o original é só <button>. -->
<button type="button" class="c-botao-origem">
<span class="c-botao-origem__circulo" aria-hidden="true"></span>
<span class="c-botao-origem__rotulo">Ver valores</span>
</button>
<a href="/espaco" class="c-botao-origem c-botao-origem--solido">
<span class="c-botao-origem__circulo" aria-hidden="true"></span>
<span class="c-botao-origem__rotulo">Explore o espaço</span>
</a>
/* ===== 05-botao-origem.prompt.md =================================================== */
You are given a task to integrate an existing React component in the codebase
The codebase should support:
- shadcn project structure
- Tailwind CSS
- Typescript
If it doesn't, provide instructions on how to setup project via shadcn CLI, install Tailwind or Typescript.
Determine the default path for components and styles.
If default path for components is not /components/ui, provide instructions on why it's important to create this folder
Copy-paste this component to /components/ui folder:
```tsx
origin-button.tsx
"use client";
import { motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
const componentThemeClassName =
"[--ic-background:#ffffff] [--ic-foreground:#111111] [--ic-primary:#111111] [--ic-secondary:#646b75] [--ic-surface-border:#e9edf2] [--ic-border:#e3e7ec] [--ic-card:#ffffff] [--ic-card-foreground:#111111] [--ic-muted:#f5f7fa] [--ic-muted-foreground:#6d7480] [--ic-accent:#f3f5f8] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--ic-accent-foreground:#111111] [--ic-input:#e3e7ec] [--ic-ring:rgba(17,17,17,0.16)] [--ic-destructive:#dc2626] [--ic-paper:#fcfcfd] [--ic-popover-foreground:#111111] [--ic-brand:#0ea5e9] [--ic-brand-soft:#bae6fd] [--ic-shadow-soft:0_18px_38px_-24px_rgba(15,23,42,0.35)] [--ic-chart-1:oklch(0.52_0.19_254)] [--ic-chart-2:oklch(0.74_0.11_232)] [--ic-chart-3:oklch(0.42_0.16_262)] [--ic-chart-4:oklch(0.84_0.07_228)] [--ic-chart-5:oklch(0.62_0.14_240)] [--color-background:var(--ic-background)] [--color-foreground:var(--ic-foreground)] [--color-primary:var(--ic-primary)] [--color-secondary:var(--ic-secondary)] [--color-border:var(--ic-border)] [--color-card:var(--ic-card)] [--color-card-foreground:var(--ic-card-foreground)] [--color-muted:var(--ic-muted)] [--color-muted-foreground:var(--ic-muted-foreground)] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--color-input:var(--ic-input)] [--color-ring:var(--ic-ring)] [--color-destructive:var(--ic-destructive)] [--color-paper:var(--ic-paper)] [--color-popover-foreground:var(--ic-popover-foreground)] [--color-brand:var(--ic-brand)] [--color-brand-soft:var(--ic-brand-soft)] [--color-chart-1:var(--ic-chart-1)] [--color-chart-2:var(--ic-chart-2)] [--color-chart-3:var(--ic-chart-3)] [--color-chart-4:var(--ic-chart-4)] [--color-chart-5:var(--ic-chart-5)] dark:[--ic-background:#111111] dark:[--ic-foreground:#f6f3ec] dark:[--ic-primary:#f6f3ec] dark:[--ic-secondary:#cbc6bb] dark:[--ic-surface-border:#2a2a25] dark:[--ic-border:#2b2a25] dark:[--ic-card:#111111] dark:[--ic-card-foreground:#f6f3ec] dark:[--ic-muted:#171716] dark:[--ic-muted-foreground:#9a958a] dark:[--ic-accent:#1a1a18] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] dark:[--ic-accent-foreground:#f6f3ec] dark:[--ic-input:#2b2a25] dark:[--ic-ring:rgba(246,243,236,0.18)] dark:[--ic-destructive:#f87171] dark:[--ic-paper:#171716] dark:[--ic-popover-foreground:#f6f3ec] dark:[--ic-brand:#38bdf8] dark:[--ic-brand-soft:#0c4a6e] dark:[--ic-shadow-soft:0_20px_44px_-28px_rgba(0,0,0,0.6)] dark:[--ic-chart-1:oklch(0.68_0.17_250)] dark:[--ic-chart-2:oklch(0.82_0.09_225)] dark:[--ic-chart-3:oklch(0.58_0.15_260)] dark:[--ic-chart-4:oklch(0.75_0.12_235)] dark:[--ic-chart-5:oklch(0.88_0.06_220)]";
const FILL_DURATION = 0.5;
const FILL_EASE = [0.16, 1, 0.3, 1] as const;
type ButtonHTMLAttributesForMotion = Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
| "onAnimationEnd"
| "onAnimationIteration"
| "onAnimationStart"
| "onDrag"
| "onDragEnd"
| "onDragEnter"
| "onDragExit"
| "onDragLeave"
| "onDragOver"
| "onDragStart"
| "onDrop"
>;
function getCoverDiameter(width: number, height: number, x: number, y: number) {
return Math.ceil(
2 *
Math.max(
Math.hypot(x, y),
Math.hypot(width - x, y),
Math.hypot(x, height - y),
Math.hypot(width - x, height - y)
)
);
}
function assignRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref) {
ref.current = value;
}
}
function hasTextContent(node: React.ReactNode): boolean {
if (typeof node === "string" || typeof node === "number") {
return String(node).trim().length > 0;
}
if (Array.isArray(node)) {
return node.some(hasTextContent);
}
if (React.isValidElement<{ children?: React.ReactNode }>(node)) {
return hasTextContent(node.props.children);
}
return false;
}
type OriginButtonProps = ButtonHTMLAttributesForMotion & {
children?: React.ReactNode;
loading?: boolean;
};
const OriginButton = React.forwardRef<HTMLButtonElement, OriginButtonProps>(
(
{
children,
className,
disabled = false,
loading = false,
type = "button",
onBlur,
onClick,
onFocus,
onKeyDown,
onKeyUp,
onPointerCancel,
onPointerDown,
onPointerEnter,
onPointerLeave,
onPointerUp,
...props
},
ref
) => {
const buttonRef = React.useRef<HTMLButtonElement>(null);
const isDisabled = Boolean(disabled || loading);
const [hovered, setHovered] = React.useState(false);
const [isPressed, setIsPressed] = React.useState(false);
const [origin, setOrigin] = React.useState({ x: 0, y: 0 });
const [coverSize, setCoverSize] = React.useState(0);
const ariaLabel = props["aria-label"];
const ariaLabelledBy = props["aria-labelledby"];
React.useEffect(() => {
if (process.env.NODE_ENV === "production") {
return;
}
if (
hasTextContent(children) ||
ariaLabel?.trim() ||
ariaLabelledBy?.trim()
) {
return;
}
console.warn(
"OriginButton: provide visible label text or aria-label / aria-labelledby so the control has an accessible name."
);
}, [ariaLabel, ariaLabelledBy, children]);
const updateOrigin = React.useCallback((x: number, y: number) => {
const node = buttonRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
setOrigin({ x, y });
setCoverSize(getCoverDiameter(rect.width, rect.height, x, y));
}, []);
const updateOriginFromPointer = React.useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
updateOrigin(event.clientX - rect.left, event.clientY - rect.top);
},
[updateOrigin]
);
const updateOriginFromCenter = React.useCallback(() => {
const node = buttonRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
updateOrigin(rect.width / 2, rect.height / 2);
}, [updateOrigin]);
const showFill = !isDisabled && (hovered || isPressed);
React.useLayoutEffect(() => {
const node = buttonRef.current;
if (!(node && showFill)) return;
const measure = () => {
const rect = node.getBoundingClientRect();
setCoverSize(
getCoverDiameter(rect.width, rect.height, origin.x, origin.y)
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(node);
const fonts = document.fonts;
if (fonts?.ready) {
fonts.ready.then(measure).catch(() => undefined);
}
return () => observer.disconnect();
}, [showFill, origin.x, origin.y]);
const fillTransition = { duration: FILL_DURATION, ease: FILL_EASE };
const setMergedRef = React.useCallback(
(node: HTMLButtonElement | null) => {
buttonRef.current = node;
assignRef(ref, node);
},
[ref]
);
return (
<motion.button
{...props}
aria-busy={loading || undefined}
className={cn(
componentThemeClassName,
"relative inline-flex h-12 cursor-pointer touch-manipulation select-none items-center justify-center overflow-hidden rounded-xl px-8 font-medium text-[15px] tracking-[-0.02em]",
"border-[0.5px] border-border bg-card text-card-foreground",
"dark:bg-muted dark:text-foreground",
"transition-[color] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
showFill && "text-background dark:text-neutral-950",
className
)}
data-pressed={isPressed ? "true" : "false"}
disabled={isDisabled}
onBlur={(event) => {
onBlur?.(event);
setIsPressed(false);
if (!event.defaultPrevented) {
setHovered(false);
}
}}
onClick={onClick}
onFocus={(event) => {
onFocus?.(event);
if (isDisabled || event.defaultPrevented) return;
if (event.currentTarget.matches(":focus-visible")) {
updateOriginFromCenter();
setHovered(true);
}
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (
event.defaultPrevented ||
isDisabled ||
event.repeat ||
(event.key !== " " && event.key !== "Enter")
) {
return;
}
if (event.key === " ") {
event.preventDefault();
}
updateOriginFromCenter();
setIsPressed(true);
setHovered(true);
}}
onKeyUp={(event) => {
onKeyUp?.(event);
if (event.key === " " || event.key === "Enter") {
setIsPressed(false);
if (!event.currentTarget.matches(":focus-visible")) {
setHovered(false);
}
}
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
setIsPressed(false);
}}
onPointerDown={(event) => {
onPointerDown?.(event);
if (event.defaultPrevented || isDisabled || event.button !== 0) {
return;
}
updateOriginFromPointer(event);
setIsPressed(true);
setHovered(true);
}}
onPointerEnter={(event) => {
onPointerEnter?.(event);
if (isDisabled || event.defaultPrevented) return;
updateOriginFromPointer(event);
setHovered(true);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
setHovered(false);
setIsPressed(false);
}}
onPointerUp={(event) => {
onPointerUp?.(event);
setIsPressed(false);
}}
ref={setMergedRef}
type={type}
whileTap={isDisabled ? undefined : { scale: 0.985 }}
>
<motion.span
animate={{ scale: showFill && coverSize > 0 ? 1 : 0 }}
aria-hidden
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground dark:bg-neutral-50"
initial={false}
style={{
height: coverSize,
left: origin.x,
top: origin.y,
width: coverSize,
}}
transition={fillTransition}
/>
<span className="relative z-10 inline-flex items-center justify-center gap-2">
{children}
</span>
</motion.button>
);
}
);
OriginButton.displayName = "OriginButton";
export { OriginButton };
export type { OriginButtonProps };
demo.tsx
"use client";
import { OriginButton } from "@/components/ui/origin-button";
export function OriginButtonPreview() {
return <OriginButton>Origin Button</OriginButton>;
}
export default OriginButtonPreview
```
Install NPM dependencies:
```bash
motion
```
Implementation Guidelines
1. Analyze the component structure and identify all required dependencies
2. Review the component's argumens and state
3. Identify any required context providers or hooks and install them
4. Questions to Ask
- What data/props will be passed to this component?
- Are there any specific state management requirements?
- Are there any required assets (images, icons, etc.)?
- What is the expected responsive behavior?
- What is the best place to use this component in the app?
Steps to integrate
0. Copy paste all the code above in the correct directories
1. Install external dependencies
2. Fill image assets with Unsplash stock images you know exist
3. Use lucide-react icons for svgs or logos if component requires them
Já rodou em
- trento-pedro · home v3 — CTAs “Ver valores”, “Reservar”, “Agendar” (degradê âmbar). Em 15/09 a navegação saiu daqui e virou vidro.
Exige
- react
- motion (motion/react)
- cn (clsx + tailwind-merge)
- Tailwind com propriedade arbitrária
Como se configura
--c-btn-fundo--c-btn-tinta--c-btn-circulo--c-btn-tinta-sobre-circulo--c-btn-linha--c-btn-raio--c-btn-altura
A ficha
O que o 21st.dev entregou
Veio como: prompt de integração do 21st.dev com o origin-button.tsx e um demo.tsx dentro, mandado pelo Rafa em 14/09 como "o prompt do botão de CTA e os botões que levam para outra página, como o de galeria".
Decisão (Rafa, 14/09):
- CTA ("Ver valores", "Reservar", "Agendar"): preenchido com o degradê amarelo → laranja.
- Botões que levam a outra página ("Explore o espaço", "Ver ensaios"): em tons de cinza escuro.
- Como o degradê se combina com o círculo de preenchimento é pergunta aberta no documento da v3.
Dependências:
react,cn, emotion(motion/react, o nome novo do Framer Motion).- Sintaxe de Tailwind com propriedade arbitrária (
[--ic-background:#ffffff]) eease-[cubic-bezier(...)]. "use client"é do Next.js.process.env.NODE_ENVsó existe com bundler.
Tema embutido: a constante componentThemeClassName declara, no próprio botão, dezenas de variáveis --ic- (fundo, texto, borda, cartão, anel de foco, marca, gráficos) com variantes dark:, e as liga às --color- do Tailwind. O botão só usa uma parte: card, card-foreground, border, muted, foreground, background, ring. As de marca e de gráfico sobram. Há duas entradas de --color-accent repetidas no meio do bloco dark:, sem o prefixo — inofensivas.
Visual em repouso:
| Propriedade | Valor |
|---|---|
| Altura | h-12 (48 px) |
| Cantos | rounded-xl (12 px) |
| Espaço lateral | px-8 (32 px) |
| Texto | 15 px, peso médio, tracking -0.02em |
| Borda | 0,5 px na cor border (#e3e7ec) |
| Fundo e texto (claro) | card branco com texto #111111 |
| Fundo e texto (escuro) | muted #171716 com texto #f6f3ec |
| Foco | anel de 2 px com recuo |
| Desabilitado | 50% de opacidade, sem clique |
Comportamento — o que dá nome ao componente:
- O círculo: ao entrar com o cursor, tocar (
pointerdown) ou focar pelo teclado, nasce um círculo (bg-foreground, escuro no tema claro) no ponto exato do toque ou do cursor — no centro, se for teclado. Ele cresce de escala 0 a 1 até cobrir o botão inteiro. O diâmetro é o dobro da distância até o canto mais longe (getCoverDiameter). - Tempo: 0,5 s, easing
cubic-bezier(0.16, 1, 0.3, 1)(sai rápido e desacelera). - Texto: troca para a cor de fundo em 0,3 s, para ficar legível sobre o círculo.
- Saída: ao sair ou soltar, o círculo encolhe de volta para o mesmo ponto.
- Aperto:
whileTapreduz o botão a 98,5%. - Remedição: um
ResizeObservere odocument.fonts.readyrefazem a conta enquanto o círculo está visível, para ele não ficar curto se o botão mudar de tamanho ou a fonte carregar depois. - Celular:
touch-manipulationtira o atraso do toque duplo eselect-noneimpede seleção de texto. O preenchimento aparece enquanto o dedo está no botão e recolhe ao soltar. - Acessibilidade: Enter e Espaço disparam o preenchimento pelo centro. Em desenvolvimento, avisa no console se o botão não tiver nome acessível.
aria-busyquandoloading.
Para a Trento:
- O componente é
<button>. Botão que leva a outra página ("Explore o espaço", "Ver ensaios") vira<a>com o mesmo visual e comportamento — é o elemento certo para navegação e é o que o Odoo espera num link. - O tema
--ic-*inteiro sai; as cores vêm dos tokens da Trento e das decisões de 14/09 (CTA em degradê amarelo → laranja, navegação em cinza escuro). - Nesta fase os botões não levam a lugar nenhum; o endereço entra na importação para o Odoo.
O que a adaptação da OCTA mudou
- Botão que leva a outra página virou
<a>com o mesmo visual — é o elemento certo para navegação. - O tema
--ic-*inteiro (dezenas de variáveis, a maioria sem uso) saiu e virou sete tokens.
Lacunas do original
Nenhuma apurada.
É o mais cuidadoso do acervo em acessibilidade: Enter e Espaço disparam o preenchimento pelo centro, aria-busy quando loading, e em desenvolvimento avisa no console se o botão não tiver nome acessível.