Buttons buttonrippleinteractivemicro-interaction

Ripple Button

A sleek interactive button that triggers localized expanding ripple rings on click.

Preview

Usage

Copy the full block below to use this component with its imports.

astro
---
import { RippleButton } from 'astro-component-kit';
---

<RippleButton variant="primary" size="md">Click Ripple</RippleButton>
--- import { RippleButton } from 'astro-component-kit'; --- <RippleButton variant="primary" size="md">Click Ripple</RippleButton>

Manual Installation

If you are not using the npm package, create a new file src/components/lib/RippleButton.astro and paste the following code:

astro
---
/**
 * RippleButton — Interactive button featuring an expanding click ripple effect with glass styling.
 * 
 * @param {'primary' | 'secondary' | 'ghost'} variant - Visual style variant. Default is 'primary'.
 * @param {'sm' | 'md' | 'lg'} size - Button size variant. Default is 'md'.
 * @param {string} href - Optional link destination.
 * @param {boolean} disabled - Whether the button is disabled.
 * @param {string} className - Additional CSS class names.
 */
interface Props {
  variant?: 'primary' | 'secondary' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  href?: string;
  disabled?: boolean;
  className?: string;
}

const {
  variant = 'primary',
  size = 'md',
  href,
  disabled = false,
  className = '',
} = Astro.props;

const Tag = href ? 'a' : 'button';
---

<Tag
  href={href}
  disabled={!href && disabled ? true : undefined}
  aria-disabled={disabled ? 'true' : undefined}
  class:list={['ripple-btn', `ripple-btn--${variant}`, `ripple-btn--${size}`, className]}
  data-ripple-btn
>
  <span class="ripple-btn__label"><slot /></span>
  <span class="ripple-btn__container" aria-hidden="true"></span>
</Tag>

<style>
  .ripple-btn {
    --btn-primary-bg: linear-gradient(135deg, rgba(184, 134, 11, 0.9), rgba(212, 160, 23, 0.9));
    --btn-primary-color: #050505;
    --btn-secondary-bg: rgba(255, 255, 255, 0.05);
    --btn-secondary-color: var(--c-text-1, #ffffff);
    --btn-ghost-bg: transparent;
    --btn-ghost-color: var(--c-primary-light, #d4a017);

    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    font-family: var(--font-sans, system-ui, sans-serif);
    font-weight: 700;
    text-decoration: none;
    border-radius: var(--r-md, 12px);
    border: 1px solid rgba(255, 255, 255, 0.1);
    cursor: pointer;
    user-select: none;
    transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, border-color 0.2s ease;
  }

  .ripple-btn:focus-visible {
    outline: 2px solid var(--c-primary-light, #d4a017);
    outline-offset: 3px;
  }

  .ripple-btn[disabled],
  .ripple-btn[aria-disabled="true"] {
    opacity: 0.5;
    cursor: not-allowed;
    pointer-events: none;
  }

  /* Variants */
  .ripple-btn--primary {
    background: var(--btn-primary-bg);
    color: var(--btn-primary-color);
    box-shadow: 0 4px 20px rgba(184, 134, 11, 0.25);
  }
  .ripple-btn--primary:hover {
    transform: translateY(-2px);
    box-shadow: 0 8px 30px rgba(184, 134, 11, 0.4);
    border-color: rgba(254, 243, 199, 0.4);
  }

  .ripple-btn--secondary {
    background: var(--btn-secondary-bg);
    color: var(--btn-secondary-color);
    backdrop-filter: blur(12px);
  }
  .ripple-btn--secondary:hover {
    background: rgba(255, 255, 255, 0.09);
    border-color: rgba(184, 134, 11, 0.3);
    transform: translateY(-2px);
  }

  .ripple-btn--ghost {
    background: var(--btn-ghost-bg);
    color: var(--btn-ghost-color);
    border-color: transparent;
  }
  .ripple-btn--ghost:hover {
    background: rgba(184, 134, 11, 0.1);
    border-color: rgba(184, 134, 11, 0.2);
  }

  /* Sizes */
  .ripple-btn--sm {
    padding: 0.45rem 1rem;
    font-size: 0.8rem;
    letter-spacing: 0.02em;
  }
  .ripple-btn--md {
    padding: 0.75rem 1.6rem;
    font-size: 0.92rem;
    letter-spacing: 0.02em;
  }
  .ripple-btn--lg {
    padding: 1rem 2.2rem;
    font-size: 1.05rem;
    letter-spacing: 0.03em;
  }

  .ripple-btn__label {
    position: relative;
    z-index: 2;
    display: inline-flex;
    align-items: center;
    gap: var(--sp-2, 0.5rem);
    pointer-events: none;
  }

  .ripple-btn__container {
    position: absolute;
    inset: 0;
    z-index: 1;
    overflow: hidden;
    pointer-events: none;
  }

  :global(.ripple-effect) {
    position: absolute;
    border-radius: 50%;
    transform: scale(0);
    animation: ripple-grow 0.6s cubic-bezier(0, 0, 0.2, 1) forwards;
    background: rgba(255, 255, 255, 0.35);
    pointer-events: none;
  }

  @keyframes ripple-grow {
    to {
      transform: scale(4);
      opacity: 0;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .ripple-btn { transition: none; }
    :global(.ripple-effect) { display: none; }
  }
</style>

<script>
  function setupRipples() {
    const buttons = document.querySelectorAll('[data-ripple-btn]');
    buttons.forEach((btn) => {
      if (btn.getAttribute('data-ripple-init')) return;
      btn.setAttribute('data-ripple-init', 'true');

      btn.addEventListener('pointerdown', (e: Event) => {
        const mouseEvent = e as MouseEvent;
        const target = mouseEvent.currentTarget as HTMLElement;
        const container = target.querySelector('.ripple-btn__container');
        if (!container) return;

        const rect = target.getBoundingClientRect();
        const size = Math.max(rect.width, rect.height);
        const x = mouseEvent.clientX - rect.left - size / 2;
        const y = mouseEvent.clientY - rect.top - size / 2;

        const ripple = document.createElement('span');
        ripple.className = 'ripple-effect';
        ripple.style.width = `${size}px`;
        ripple.style.height = `${size}px`;
        ripple.style.left = `${x}px`;
        ripple.style.top = `${y}px`;

        container.appendChild(ripple);
        setTimeout(() => ripple.remove(), 650);
      });
    });
  }

  setupRipples();
  document.addEventListener('astro:page-load', setupRipples);
</script>
--- /** * RippleButton — Interactive button featuring an expanding click ripple effect with glass styling. * * @param {'primary' | 'secondary' | 'ghost'} variant - Visual style variant. Default is 'primary'. * @param {'sm' | 'md' | 'lg'} size - Button size variant. Default is 'md'. * @param {string} href - Optional link destination. * @param {boolean} disabled - Whether the button is disabled. * @param {string} className - Additional CSS class names. */ interface Props { variant?: 'primary' | 'secondary' | 'ghost'; size?: 'sm' | 'md' | 'lg'; href?: string; disabled?: boolean; className?: string; } const { variant = 'primary', size = 'md', href, disabled = false, className = '', } = Astro.props; const Tag = href ? 'a' : 'button'; --- <Tag href={href} disabled={!href && disabled ? true : undefined} aria-disabled={disabled ? 'true' : undefined} class:list={['ripple-btn', `ripple-btn--${variant}`, `ripple-btn--${size}`, className]} data-ripple-btn > <span class="ripple-btn__label"><slot /></span> <span class="ripple-btn__container" aria-hidden="true"></span> </Tag> <style> .ripple-btn { --btn-primary-bg: linear-gradient(135deg, rgba(184, 134, 11, 0.9), rgba(212, 160, 23, 0.9)); --btn-primary-color: #050505; --btn-secondary-bg: rgba(255, 255, 255, 0.05); --btn-secondary-color: var(--c-text-1, #ffffff); --btn-ghost-bg: transparent; --btn-ghost-color: var(--c-primary-light, #d4a017); position: relative; display: inline-flex; align-items: center; justify-content: center; overflow: hidden; font-family: var(--font-sans, system-ui, sans-serif); font-weight: 700; text-decoration: none; border-radius: var(--r-md, 12px); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; user-select: none; transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, border-color 0.2s ease; } .ripple-btn:focus-visible { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 3px; } .ripple-btn[disabled], .ripple-btn[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; pointer-events: none; } /* Variants */ .ripple-btn--primary { background: var(--btn-primary-bg); color: var(--btn-primary-color); box-shadow: 0 4px 20px rgba(184, 134, 11, 0.25); } .ripple-btn--primary:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(184, 134, 11, 0.4); border-color: rgba(254, 243, 199, 0.4); } .ripple-btn--secondary { background: var(--btn-secondary-bg); color: var(--btn-secondary-color); backdrop-filter: blur(12px); } .ripple-btn--secondary:hover { background: rgba(255, 255, 255, 0.09); border-color: rgba(184, 134, 11, 0.3); transform: translateY(-2px); } .ripple-btn--ghost { background: var(--btn-ghost-bg); color: var(--btn-ghost-color); border-color: transparent; } .ripple-btn--ghost:hover { background: rgba(184, 134, 11, 0.1); border-color: rgba(184, 134, 11, 0.2); } /* Sizes */ .ripple-btn--sm { padding: 0.45rem 1rem; font-size: 0.8rem; letter-spacing: 0.02em; } .ripple-btn--md { padding: 0.75rem 1.6rem; font-size: 0.92rem; letter-spacing: 0.02em; } .ripple-btn--lg { padding: 1rem 2.2rem; font-size: 1.05rem; letter-spacing: 0.03em; } .ripple-btn__label { position: relative; z-index: 2; display: inline-flex; align-items: center; gap: var(--sp-2, 0.5rem); pointer-events: none; } .ripple-btn__container { position: absolute; inset: 0; z-index: 1; overflow: hidden; pointer-events: none; } :global(.ripple-effect) { position: absolute; border-radius: 50%; transform: scale(0); animation: ripple-grow 0.6s cubic-bezier(0, 0, 0.2, 1) forwards; background: rgba(255, 255, 255, 0.35); pointer-events: none; } @keyframes ripple-grow { to { transform: scale(4); opacity: 0; } } @media (prefers-reduced-motion: reduce) { .ripple-btn { transition: none; } :global(.ripple-effect) { display: none; } } </style> <script> function setupRipples() { const buttons = document.querySelectorAll('[data-ripple-btn]'); buttons.forEach((btn) => { if (btn.getAttribute('data-ripple-init')) return; btn.setAttribute('data-ripple-init', 'true'); btn.addEventListener('pointerdown', (e: Event) => { const mouseEvent = e as MouseEvent; const target = mouseEvent.currentTarget as HTMLElement; const container = target.querySelector('.ripple-btn__container'); if (!container) return; const rect = target.getBoundingClientRect(); const size = Math.max(rect.width, rect.height); const x = mouseEvent.clientX - rect.left - size / 2; const y = mouseEvent.clientY - rect.top - size / 2; const ripple = document.createElement('span'); ripple.className = 'ripple-effect'; ripple.style.width = `${size}px`; ripple.style.height = `${size}px`; ripple.style.left = `${x}px`; ripple.style.top = `${y}px`; container.appendChild(ripple); setTimeout(() => ripple.remove(), 650); }); }); } setupRipples(); document.addEventListener('astro:page-load', setupRipples); </script>

Quick Info

Category
Buttons
Filename
RippleButton.astro
Dependencies
None — pure Astro + CSS
Tags
buttonrippleinteractivemicro-interaction