Inspired by Emil Kowalski

Feedback Rails Components

Beautiful animated feedback components that transform from a simple button into an elegant form. Built with Motion.dev for smooth, performant animations.

Installation

1. Stimulus Controller Setup

Start by adding the following controller to your project. This controller uses Motion.dev for smooth animations:

import { Controller } from "@hotwired/stimulus";
import { animate, stagger, spring, delay } from "motion";

export default class extends Controller {
  static targets = ["button", "buttonText", "form", "textarea"];
  static values = {
    expanded: { type: Boolean, default: false },
    anchorPoint: { type: String, default: "center" },
  };

  // Get anchor point configuration (transform origin, positioning, and transforms)
  getAnchorConfig() {
    switch (this.anchorPointValue) {
      case "top-left":
        return {
          transformOrigin: "top left",
          positioning: { top: "0", left: "0" },
          baseTransform: "",
        };
      case "top":
        return {
          transformOrigin: "top center",
          positioning: { top: "0", left: "50%" },
          baseTransform: "translateX(-50%)",
        };
      case "top-right":
        return {
          transformOrigin: "top right",
          positioning: { top: "0", right: "0" },
          baseTransform: "",
        };
      case "left":
        return {
          transformOrigin: "center left",
          positioning: { top: "50%", left: "0" },
          baseTransform: "translateY(-50%)",
        };
      case "right":
        return {
          transformOrigin: "center right",
          positioning: { top: "50%", right: "0" },
          baseTransform: "translateY(-50%)",
        };
      case "bottom-left":
        return {
          transformOrigin: "bottom left",
          positioning: { bottom: "0", left: "0" },
          baseTransform: "",
        };
      case "bottom":
        return {
          transformOrigin: "bottom center",
          positioning: { bottom: "0", left: "50%" },
          baseTransform: "translateX(-50%)",
        };
      case "bottom-right":
        return {
          transformOrigin: "bottom right",
          positioning: { bottom: "0", right: "0" },
          baseTransform: "",
        };
      case "center":
      default:
        return {
          transformOrigin: "center center",
          positioning: { top: "50%", left: "50%" },
          baseTransform: "translate(-50%, -50%)",
        };
    }
  }

  // Reset positioning and transform styles
  resetPositioningStyles(element) {
    element.style.top = "";
    element.style.left = "";
    element.style.right = "";
    element.style.bottom = "";
    element.style.transform = "";
  }

  // Apply positioning styles to the form based on anchor point value
  applyPositioning() {
    if (!this.hasFormTarget) return;

    const config = this.getAnchorConfig();

    // Reset all positioning first
    this.resetPositioningStyles(this.formTarget);

    // Apply positioning from config
    Object.entries(config.positioning).forEach(([property, value]) => {
      this.formTarget.style[property] = value;
    });

    // Apply base transform if present
    if (config.baseTransform) {
      this.formTarget.style.transform = config.baseTransform;
    }
  }

  // Measure and cache the button's natural size (without inline overrides)
  measureInitialButtonSize() {
    if (!this.hasButtonTarget) return;

    // Ensure the button is visible and has computed styles
    if (this.buttonTarget.offsetParent === null || this.buttonTarget.offsetWidth === 0) {
      // Element not visible yet, defer measurement
      return false;
    }

    const rect = this.buttonTarget.getBoundingClientRect();
    this.initialButtonWidth = Math.round(rect.width);
    this.initialButtonHeight = Math.round(rect.height);

    // Store and immediately apply the original button width to prevent any layout shifts
    this.buttonTarget.style.width = `${this.initialButtonWidth}px`;

    return true;
  }

  // Deferred initialization that waits for the DOM to be ready
  deferredInitialization() {
    // Use requestAnimationFrame to ensure DOM is fully rendered
    requestAnimationFrame(() => {
      const measured = this.measureInitialButtonSize();
      if (!measured) {
        // If measurement failed, try again after a short delay
        delay(() => {
          this.measureInitialButtonSize();
        }, 0.05);
      }
    });
  }

  setupInitialState() {
    this.resetToButtonState();
    this.resetToFormState();
  }

  resetToFormState() {
    if (this.hasFormTarget) {
      // Hide form initially and reset all properties
      this.formTarget.style.display = "none";
      this.formTarget.style.opacity = "0";
      this.formTarget.style.transformOrigin = "";
      this.formTarget.style.width = "";
      this.formTarget.style.height = "";
      this.formTarget.style.zIndex = "";
      this.formTarget.style.borderRadius = "";

      // Apply positioning based on position value
      this.applyPositioning();

      // Set initial state for form elements to enable stagger animation
      const formElements = [
        this.formTarget.querySelector("textarea"),
        this.formTarget.querySelector('button[type="submit"]'),
      ].filter(Boolean);

      formElements.forEach((element) => {
        if (element) {
          element.style.opacity = "0";
          element.style.transform = "translateY(10px)";
        }
      });
    }
  }

  resetToButtonState() {
    if (this.hasButtonTarget) {
      this.buttonTarget.style.transform = "scale(1)";
      this.buttonTarget.style.opacity = "1";
      this.buttonTarget.style.pointerEvents = "auto";
      this.buttonTarget.style.display = "flex";
      // Keep the measured width instead of resetting to auto
      if (this.initialButtonWidth) {
        this.buttonTarget.style.width = `${this.initialButtonWidth}px`;
      }
      this.buttonTarget.style.height = "auto";
      this.buttonTarget.style.borderRadius = "8px";

      // Force a reflow to ensure the button is interactive
      this.buttonTarget.offsetHeight;
    }

    if (this.hasButtonTextTarget) {
      this.buttonTextTarget.style.transform = "none";
      this.buttonTextTarget.style.opacity = "1";
      this.buttonTextTarget.style.pointerEvents = "none";
    }
  }

  async toggle() {
    if (this.expandedValue) {
      await this.collapse();
    } else {
      await this.expand();
    }
  }

  async expand() {
    if (this.expandedValue) return;

    this.expandedValue = true;

    // Show form
    this.formTarget.style.display = "block";
    this.formTarget.style.zIndex = "50";

    // Temporarily disable button clicks during animation
    this.buttonTarget.style.pointerEvents = "none";
    this.buttonTextTarget.style.pointerEvents = "none";

    // Get button dimensions for smooth transition
    const buttonRect = this.buttonTarget.getBoundingClientRect();

    // Ensure form is properly measured by forcing a layout
    this.formTarget.offsetHeight;

    // Measure target form dimensions from CSS (ignores transforms)
    const formWidth = this.formTarget.offsetWidth;
    const formHeight = this.formTarget.offsetHeight;

    // Calculate scale factors for performant transform-based animation
    const scaleX = formWidth / buttonRect.width;
    const scaleY = formHeight / buttonRect.height;

    // Apply positioning and set form to final size but scaled down to button size initially
    this.applyPositioning();
    this.formTarget.style.width = `${formWidth}px`;
    this.formTarget.style.height = `${formHeight}px`;
    this.formTarget.style.transformOrigin = this.getAnchorConfig().transformOrigin;

    // Handle positioning with existing transforms
    const baseTransform = this.getAnchorConfig().baseTransform;
    this.formTarget.style.transform = `${baseTransform} scale(${1 / scaleX}, ${1 / scaleY})`;
    this.formTarget.style.opacity = "0";

    // Use transform-based animation for better performance (avoids layout recalculation)
    const finalTransform = `${baseTransform} scale(1, 1)`;
    const formAnimation = animate(
      this.formTarget,
      {
        opacity: 1,
        transform: finalTransform,
      },
      {
        type: spring,
        stiffness: 300,
        damping: 25,
      }
    );

    // Animate border radius on the form
    const borderAnimation = animate(
      this.formTarget,
      {
        borderRadius: "12px",
      },
      {
        type: spring,
        stiffness: 300,
        damping: 25,
      }
    );

    // Simply fade out the button instead of resizing it
    const buttonAnimation = animate(
      this.buttonTarget,
      {
        opacity: 0,
        transform: "scale(0.90)",
      },
      {
        type: spring,
        stiffness: 420,
        damping: 32,
        onComplete: () => {
          // Ensure button stays in the final state
          this.buttonTarget.style.opacity = "0";
          this.buttonTarget.style.transform = "scale(0.90)";
        },
      }
    );

    // Stagger animate form elements for polished entrance
    delay(() => {
      if (this.hasTextareaTarget) {
        const formElements = [
          this.formTarget.querySelector("textarea"),
          this.formTarget.querySelector('button[type="submit"]'),
        ].filter(Boolean);

        if (formElements.length > 0) {
          animate(
            formElements,
            {
              opacity: [0, 1],
              y: [10, 0],
            },
            {
              type: spring,
              bounce: 0.35,
              duration: 0.25,
              delay: stagger(0.0, { startDelay: 0.15 }),
            }
          );
        }
      }
    }, 0.05);

    // Animate textarea focus
    if (this.hasTextareaTarget) {
      delay(() => {
        this.textareaTarget.focus();
      }, 0.3);
    }

    await Promise.all([formAnimation.finished, borderAnimation.finished, buttonAnimation.finished]);

    // Re-enable form interactions once fully expanded
    this.formTarget.style.pointerEvents = "auto";
  }

  async collapse() {
    if (!this.expandedValue) return;

    this.expandedValue = false;
    this.formTarget.style.zIndex = "50";
    this.formTarget.style.pointerEvents = "none";

    // Use the cached button dimensions measured on page load
    // Fallback to current measurements if initial size wasn't captured
    if (!this.initialButtonWidth || !this.initialButtonHeight) {
      this.measureInitialButtonSize();
    }

    // Fade out form contents
    const formInnerElements = [
      this.formTarget.querySelector("textarea"),
      this.formTarget.querySelector('button[type="submit"]'),
    ].filter(Boolean);

    if (formInnerElements.length > 0) {
      animate(
        formInnerElements,
        {
          opacity: 0,
        },
        {
          duration: 0.15,
        }
      );
    }

    // Use performant transform-based collapse animation
    const currentFormRect = this.formTarget.getBoundingClientRect();
    const scaleToButtonX = this.initialButtonWidth / currentFormRect.width;
    const scaleToButtonY = this.initialButtonHeight / currentFormRect.height;

    const animationOptions = {
      type: spring,
      stiffness: 300,
      damping: 30,
    };

    // Scale down the form to button size and fade out
    const baseTransform = this.getAnchorConfig().baseTransform;
    const collapseTransform = `${baseTransform} scale(${scaleToButtonX}, ${scaleToButtonY})`;

    const formAnimation = animate(
      this.formTarget,
      {
        transform: collapseTransform,
        opacity: 0,
        borderRadius: "8px",
      },
      animationOptions
    );

    // Show the button by scaling it back up and fading in
    const buttonAnimation = animate(
      this.buttonTarget,
      {
        opacity: 1,
        transform: "scale(1)",
      },
      {
        ...animationOptions,
        onComplete: () => {
          // Ensure button stays in the final state
          this.buttonTarget.style.opacity = "1";
          this.buttonTarget.style.transform = "scale(1)";
        },
      }
    );

    // Wait for both animations to finish
    await Promise.all([buttonAnimation.finished, formAnimation.finished]);

    // Reset form state
    this.resetToFormState();

    // Set final button state after animation completes
    if (this.hasButtonTarget) {
      this.buttonTarget.style.pointerEvents = "auto";
      this.buttonTarget.style.display = "flex";
      if (this.initialButtonWidth) {
        this.buttonTarget.style.width = `${this.initialButtonWidth}px`;
      }
      this.buttonTarget.style.height = "auto";
      this.buttonTarget.style.borderRadius = "8px";
    }

    if (this.hasButtonTextTarget) {
      this.buttonTextTarget.style.pointerEvents = "none";
    }
  }

  // Show success state with smooth animation
  async showSuccessState() {
    if (!this.hasButtonTextTarget) return;

    // Store original text if not already stored
    if (!this.originalButtonText) {
      this.originalButtonText = this.buttonTextTarget.textContent.trim();
    }

    // Animate text fade out
    await animate(
      this.buttonTextTarget,
      {
        opacity: 0,
        y: -5,
      },
      {
        duration: 0,
      }
    ).finished;

    // Change content to success state
    this.buttonTextTarget.innerHTML = `
      <svg xmlns="http://www.w3.org/2000/svg" class="inline-block size-3.5" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" fill="none" stroke="currentColor" class="nc-icon-wrapper" d="M2.75 9.25L6.75 14.25 15.25 3.75"></path></g></svg>
      Sent
    `;

    // Animate text fade in
    await animate(
      this.buttonTextTarget,
      {
        opacity: 1,
        y: 0,
      },
      {
        duration: 0.2,
      }
    ).finished;
  }

  // Reset button text to original state
  async resetButtonText() {
    if (!this.hasButtonTextTarget || !this.originalButtonText) return;

    // Animate text fade out
    await animate(
      this.buttonTextTarget,
      {
        opacity: 0,
        y: 5,
      },
      {
        duration: 0.25,
        type: spring,
        bounce: 0.25,
      }
    ).finished;

    // Reset to original text
    this.buttonTextTarget.textContent = this.originalButtonText;

    // Animate text fade in
    await animate(
      this.buttonTextTarget,
      {
        opacity: 1,
        y: 0,
      },
      {
        duration: 0.2,
        type: spring,
        bounce: 0.25,
      }
    ).finished;
  }

  // Handle form submission
  async submit(event) {
    event.preventDefault();

    // Show success state on button
    this.showSuccessState();

    // Add submit logic here
    console.log("Feedback submitted:", this.textareaTarget.value);

    // Collapse after showing success
    await this.collapse();

    // Clear the form
    if (this.hasTextareaTarget) {
      this.textareaTarget.value = "";
    }

    // Reset button to original state after a delay
    await this.resetButtonText();
  }

  // Close when clicking outside
  handleOutsideClick(event) {
    if (this.expandedValue && !this.element.contains(event.target)) {
      this.collapse();
    }
  }

  // Close when mouse down outside
  handleOutsideMouseDown(event) {
    if (this.expandedValue && !this.element.contains(event.target)) {
      this.collapse();
    }
  }

  // Handle escape key
  async handleEscapeKey(event) {
    if (event.key === "Escape" && this.expandedValue) {
      await this.collapse();
      // Focus the button after collapsing with escape
      if (this.hasButtonTarget) {
        this.buttonTarget.focus();
      }
    }
  }

  // Handle keyboard shortcuts on textarea
  handleTextareaKeyDown(event) {
    // Check for Ctrl+Enter or Cmd+Enter to submit
    if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
      event.preventDefault();
      // Trigger the form's submit event to respect validation
      const form = this.formTarget.querySelector("form");
      if (form) {
        form.requestSubmit();
      }
    }
  }

  // Handle Turbo navigation cleanup
  beforeCache() {
    // Ensure component is in collapsed state before page caching
    if (this.expandedValue) {
      this.expandedValue = false;
      this.resetToButtonState();
      this.resetToFormState();
    }

    // Reset button text to original state
    if (this.hasButtonTextTarget && this.originalButtonText) {
      this.buttonTextTarget.textContent = this.originalButtonText;
      this.buttonTextTarget.style.opacity = "1";
      this.buttonTextTarget.style.transform = "none";
    }

    // Maintain button width
    if (this.hasButtonTarget && this.initialButtonWidth) {
      this.buttonTarget.style.width = `${this.initialButtonWidth}px`;
    }
  }

  beforeVisit() {
    // Clean up any ongoing animations before navigation
    if (this.expandedValue) {
      this.expandedValue = false;
    }
  }

  // Add event listeners when component mounts
  connect() {
    // Initialize state
    this.expandedValue = false;
    this.initialButtonWidth = null;
    this.initialButtonHeight = null;
    this.originalButtonText = null;

    this.setupInitialState();

    // Use deferred initialization to handle Turbo timing issues
    this.deferredInitialization();

    // Bind event handlers
    this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
    this.boundHandleOutsideMouseDown = this.handleOutsideMouseDown.bind(this);
    this.boundHandleEscapeKey = this.handleEscapeKey.bind(this);
    this.boundHandleTextareaKeyDown = this.handleTextareaKeyDown.bind(this);
    this.boundBeforeCache = this.beforeCache.bind(this);
    this.boundBeforeVisit = this.beforeVisit.bind(this);

    // Add document event listeners
    document.addEventListener("click", this.boundHandleOutsideClick);
    document.addEventListener("mousedown", this.boundHandleOutsideMouseDown);
    document.addEventListener("keydown", this.boundHandleEscapeKey);

    // Add textarea-specific event listener for keyboard shortcuts
    if (this.hasTextareaTarget) {
      this.textareaTarget.addEventListener("keydown", this.boundHandleTextareaKeyDown);
    }

    // Add Turbo event listeners
    document.addEventListener("turbo:before-cache", this.boundBeforeCache);
    document.addEventListener("turbo:before-visit", this.boundBeforeVisit);
  }

  // Remove event listeners when component unmounts
  disconnect() {
    // Clean up component state
    if (this.expandedValue) {
      this.expandedValue = false;
    }

    // Reset button text to original state
    if (this.hasButtonTextTarget && this.originalButtonText) {
      this.buttonTextTarget.textContent = this.originalButtonText;
      this.buttonTextTarget.style.opacity = "1";
      this.buttonTextTarget.style.transform = "none";
    }

    // Maintain button width
    if (this.hasButtonTarget && this.initialButtonWidth) {
      this.buttonTarget.style.width = `${this.initialButtonWidth}px`;
    }

    // Remove all event listeners
    document.removeEventListener("click", this.boundHandleOutsideClick);
    document.removeEventListener("mousedown", this.boundHandleOutsideMouseDown);
    document.removeEventListener("keydown", this.boundHandleEscapeKey);
    document.removeEventListener("turbo:before-cache", this.boundBeforeCache);
    document.removeEventListener("turbo:before-visit", this.boundBeforeVisit);

    // Remove textarea-specific event listener
    if (this.hasTextareaTarget) {
      this.textareaTarget.removeEventListener("keydown", this.boundHandleTextareaKeyDown);
    }
  }
}

2. Motion.dev Installation

pin "motion", to: "https://cdn.jsdelivr.net/npm/motion@latest/+esm"
Terminal
npm install motion
Terminal
yarn add motion

Examples

Basic Feedback

A basic feedback component that animates from a button to a form using Motion.dev. Click the button to see the smooth transformation.

<div class="flex items-center justify-center relative" data-controller="feedback" data-feedback-expanded-value="false" data-feedback-anchor-point-value="center" data-feedback-target="container">
  <!-- Button -->
  <button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-black/10 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-feedback-target="button" data-action="click->feedback#toggle">
    <span class="flex items-center gap-1.5" data-feedback-target="buttonText">
      Feedback
    </span>
  </button>

  <!-- Form -->
  <div class="absolute hidden h-32 w-64 sm:h-48 sm:w-96 overflow-hidden rounded-lg border border-black/5 bg-neutral-100 p-1.5 shadow-xs outline-none dark:border-white/10 dark:bg-neutral-900 dark:shadow-neutral-900/50" data-feedback-target="form">
    <form class="flex h-full flex-col overflow-hidden rounded-lg border border-black/10 bg-white dark:border-neutral-600 dark:bg-neutral-700/75" data-action="submit->feedback#submit">

      <textarea class="small-scrollbar w-full flex-1 resize-none rounded-t-lg p-3 text-sm text-black placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400"
                required
                placeholder="Tell us what you think..."
                data-feedback-target="textarea"></textarea>

      <div class="relative flex h-12 items-center border-t border-dashed border-black/10 bg-neutral-50 px-2.5 dark:border-white/20 dark:bg-neutral-800">
        <button type="submit" class="ml-auto flex items-center justify-center rounded-md bg-neutral-800 py-1 px-2 text-xs font-medium text-white transition-colors duration-200 hover:bg-neutral-700 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100">
          <span>
            Send feedback
          </span>
        </button>
      </div>

    </form>
  </div>
</div>

Button Anchor Point Examples

The feedback component supports different anchor point options using the data-feedback-anchor-point-value attribute. The form will expand from the button while maintaining the specified anchor point.

Top Left Anchor

Perfect for buttons placed in the top-left corner of the screen or container.

<div class="flex items-start justify-center relative" data-controller="feedback" data-feedback-expanded-value="false" data-feedback-anchor-point-value="top-left" data-feedback-target="container">
  <!-- Button -->
  <button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-black/10 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-feedback-target="button" data-action="click->feedback#toggle">
    <span class="flex items-center gap-1.5" data-feedback-target="buttonText">
      Feedback
    </span>
  </button>

  <!-- Form -->
  <div class="absolute hidden h-32 w-64 sm:h-48 sm:w-96 overflow-hidden rounded-lg border border-black/5 bg-neutral-100 p-1.5 shadow-xs outline-none dark:border-white/10 dark:bg-neutral-900 dark:shadow-neutral-900/50" data-feedback-target="form">
    <form class="flex h-full flex-col overflow-hidden rounded-lg border border-black/10 bg-white dark:border-neutral-600 dark:bg-neutral-700/75" data-action="submit->feedback#submit">

      <textarea class="small-scrollbar w-full flex-1 resize-none rounded-t-lg p-3 text-sm text-black placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400"
                required
                placeholder="Tell us what you think..."
                data-feedback-target="textarea"></textarea>

      <div class="relative flex h-12 items-center border-t border-dashed border-black/10 bg-neutral-50 px-2.5 dark:border-white/20 dark:bg-neutral-800">
        <button type="submit" class="ml-auto flex items-center justify-center rounded-md bg-neutral-800 py-1 px-2 text-xs font-medium text-white transition-colors duration-200 hover:bg-neutral-700 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100">
          <span>
            Send feedback
          </span>
        </button>
      </div>

    </form>
  </div>
</div>

Top Right Anchor

Ideal for navigation menus or toolbar buttons in the top-right area.

<div class="flex items-start justify-center relative" data-controller="feedback" data-feedback-expanded-value="false" data-feedback-anchor-point-value="top-right" data-feedback-target="container">
  <!-- Button -->
  <button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-black/10 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-feedback-target="button" data-action="click->feedback#toggle">
    <span class="flex items-center gap-1.5" data-feedback-target="buttonText">
      Feedback
    </span>
  </button>

  <!-- Form -->
  <div class="absolute hidden h-32 w-64 sm:h-48 sm:w-96 overflow-hidden rounded-lg border border-black/5 bg-neutral-100 p-1.5 shadow-xs outline-none dark:border-white/10 dark:bg-neutral-900 dark:shadow-neutral-900/50" data-feedback-target="form">
    <form class="flex h-full flex-col overflow-hidden rounded-lg border border-black/10 bg-white dark:border-neutral-600 dark:bg-neutral-700/75" data-action="submit->feedback#submit">

      <textarea class="small-scrollbar w-full flex-1 resize-none rounded-t-lg p-3 text-sm text-black placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400"
                required
                placeholder="Tell us what you think..."
                data-feedback-target="textarea"></textarea>

      <div class="relative flex h-12 items-center border-t border-dashed border-black/10 bg-neutral-50 px-2.5 dark:border-white/20 dark:bg-neutral-800">
        <button type="submit" class="ml-auto flex items-center justify-center rounded-md bg-neutral-800 py-1 px-2 text-xs font-medium text-white transition-colors duration-200 hover:bg-neutral-700 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100">
          <span>
            Send feedback
          </span>
        </button>
      </div>

    </form>
  </div>
</div>

Bottom Left Anchor

Great for sidebar or footer buttons positioned at the bottom-left.

<div class="flex items-end justify-center relative" data-controller="feedback" data-feedback-expanded-value="false" data-feedback-anchor-point-value="bottom-left" data-feedback-target="container">
  <!-- Button -->
  <button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-black/10 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-feedback-target="button" data-action="click->feedback#toggle">
    <span class="flex items-center gap-1.5" data-feedback-target="buttonText">
      Feedback
    </span>
  </button>

  <!-- Form -->
  <div class="absolute hidden h-32 w-64 sm:h-48 sm:w-96 overflow-hidden rounded-lg border border-black/5 bg-neutral-100 p-1.5 shadow-xs outline-none dark:border-white/10 dark:bg-neutral-900 dark:shadow-neutral-900/50" data-feedback-target="form">
    <form class="flex h-full flex-col overflow-hidden rounded-lg border border-black/10 bg-white dark:border-neutral-600 dark:bg-neutral-700/75" data-action="submit->feedback#submit">

      <textarea class="small-scrollbar w-full flex-1 resize-none rounded-t-lg p-3 text-sm text-black placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400"
                required
                placeholder="Tell us what you think..."
                data-feedback-target="textarea"></textarea>

      <div class="relative flex h-12 items-center border-t border-dashed border-black/10 bg-neutral-50 px-2.5 dark:border-white/20 dark:bg-neutral-800">
        <button type="submit" class="ml-auto flex items-center justify-center rounded-md bg-neutral-800 py-1 px-2 text-xs font-medium text-white transition-colors duration-200 hover:bg-neutral-700 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100">
          <span>
            Send feedback
          </span>
        </button>
      </div>

    </form>
  </div>
</div>

Bottom Right Anchor

Commonly used for floating action buttons or chat widgets in the bottom-right corner.

<div class="flex items-end justify-center relative" data-controller="feedback" data-feedback-expanded-value="false" data-feedback-anchor-point-value="bottom-right" data-feedback-target="container">
  <!-- Button -->
  <button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-black/10 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-feedback-target="button" data-action="click->feedback#toggle">
    <span class="flex items-center gap-1.5" data-feedback-target="buttonText">
      Feedback
    </span>
  </button>

  <!-- Form -->
  <div class="absolute hidden h-32 w-64 sm:h-48 sm:w-96 overflow-hidden rounded-lg border border-black/5 bg-neutral-100 p-1.5 shadow-xs outline-none dark:border-white/10 dark:bg-neutral-900 dark:shadow-neutral-900/50" data-feedback-target="form">
    <form class="flex h-full flex-col overflow-hidden rounded-lg border border-black/10 bg-white dark:border-neutral-600 dark:bg-neutral-700/75" data-action="submit->feedback#submit">

      <textarea class="small-scrollbar w-full flex-1 resize-none rounded-t-lg p-3 text-sm text-black placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400"
                required
                placeholder="Tell us what you think..."
                data-feedback-target="textarea"></textarea>

      <div class="relative flex h-12 items-center border-t border-dashed border-black/10 bg-neutral-50 px-2.5 dark:border-white/20 dark:bg-neutral-800">
        <button type="submit" class="ml-auto flex items-center justify-center rounded-md bg-neutral-800 py-1 px-2 text-xs font-medium text-white transition-colors duration-200 hover:bg-neutral-700 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100">
          <span>
            Send feedback
          </span>
        </button>
      </div>

    </form>
  </div>
</div>

Top Center Anchor

Ideal for toolbar buttons or navigation elements centered at the top.

<div class="flex items-start justify-center relative" data-controller="feedback" data-feedback-expanded-value="false" data-feedback-anchor-point-value="top" data-feedback-target="container">
  <!-- Button -->
  <button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-black/10 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-feedback-target="button" data-action="click->feedback#toggle">
    <span class="flex items-center gap-1.5" data-feedback-target="buttonText">
      Feedback
    </span>
  </button>

  <!-- Form -->
  <div class="absolute hidden h-32 w-64 sm:h-48 sm:w-96 overflow-hidden rounded-lg border border-black/5 bg-neutral-100 p-1.5 shadow-xs outline-none dark:border-white/10 dark:bg-neutral-900 dark:shadow-neutral-900/50" data-feedback-target="form">
    <form class="flex h-full flex-col overflow-hidden rounded-lg border border-black/10 bg-white dark:border-neutral-600 dark:bg-neutral-700/75" data-action="submit->feedback#submit">

      <textarea class="small-scrollbar w-full flex-1 resize-none rounded-t-lg p-3 text-sm text-black placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400"
                required
                placeholder="Tell us what you think..."
                data-feedback-target="textarea"></textarea>

      <div class="relative flex h-12 items-center border-t border-dashed border-black/10 bg-neutral-50 px-2.5 dark:border-white/20 dark:bg-neutral-800">
        <button type="submit" class="ml-auto flex items-center justify-center rounded-md bg-neutral-800 py-1 px-2 text-xs font-medium text-white transition-colors duration-200 hover:bg-neutral-700 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100">
          <span>
            Send feedback
          </span>
        </button>
      </div>

    </form>
  </div>
</div>

Center Left Anchor

Perfect for sidebar buttons or left-aligned navigation elements.

<div class="flex items-center justify-start relative" data-controller="feedback" data-feedback-expanded-value="false" data-feedback-anchor-point-value="left" data-feedback-target="container">
  <!-- Button -->
  <button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-black/10 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-feedback-target="button" data-action="click->feedback#toggle">
    <span class="flex items-center gap-1.5" data-feedback-target="buttonText">
      Feedback
    </span>
  </button>

  <!-- Form -->
  <div class="absolute hidden h-32 w-64 sm:h-48 sm:w-96 overflow-hidden rounded-lg border border-black/5 bg-neutral-100 p-1.5 shadow-xs outline-none dark:border-white/10 dark:bg-neutral-900 dark:shadow-neutral-900/50" data-feedback-target="form">
    <form class="flex h-full flex-col overflow-hidden rounded-lg border border-black/10 bg-white dark:border-neutral-600 dark:bg-neutral-700/75" data-action="submit->feedback#submit">

      <textarea class="small-scrollbar w-full flex-1 resize-none rounded-t-lg p-3 text-sm text-black placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400"
                required
                placeholder="Tell us what you think..."
                data-feedback-target="textarea"></textarea>

      <div class="relative flex h-12 items-center border-t border-dashed border-black/10 bg-neutral-50 px-2.5 dark:border-white/20 dark:bg-neutral-800">
        <button type="submit" class="ml-auto flex items-center justify-center rounded-md bg-neutral-800 py-1 px-2 text-xs font-medium text-white transition-colors duration-200 hover:bg-neutral-700 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100">
          <span>
            Send feedback
          </span>
        </button>
      </div>

    </form>
  </div>
</div>

Center Right Anchor

Great for side panels or right-aligned toolbar buttons.

<div class="flex items-center justify-end relative" data-controller="feedback" data-feedback-expanded-value="false" data-feedback-anchor-point-value="right" data-feedback-target="container">
  <!-- Button -->
  <button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-black/10 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-feedback-target="button" data-action="click->feedback#toggle">
    <span class="flex items-center gap-1.5" data-feedback-target="buttonText">
      Feedback
    </span>
  </button>

  <!-- Form -->
  <div class="absolute hidden h-32 w-64 sm:h-48 sm:w-96 overflow-hidden rounded-lg border border-black/5 bg-neutral-100 p-1.5 shadow-xs outline-none dark:border-white/10 dark:bg-neutral-900 dark:shadow-neutral-900/50" data-feedback-target="form">
    <form class="flex h-full flex-col overflow-hidden rounded-lg border border-black/10 bg-white dark:border-neutral-600 dark:bg-neutral-700/75" data-action="submit->feedback#submit">

      <textarea class="small-scrollbar w-full flex-1 resize-none rounded-t-lg p-3 text-sm text-black placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400"
                required
                placeholder="Tell us what you think..."
                data-feedback-target="textarea"></textarea>

      <div class="relative flex h-12 items-center border-t border-dashed border-black/10 bg-neutral-50 px-2.5 dark:border-white/20 dark:bg-neutral-800">
        <button type="submit" class="ml-auto flex items-center justify-center rounded-md bg-neutral-800 py-1 px-2 text-xs font-medium text-white transition-colors duration-200 hover:bg-neutral-700 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100">
          <span>
            Send feedback
          </span>
        </button>
      </div>

    </form>
  </div>
</div>

Bottom Center Anchor

Excellent for fixed bottom bars or footer action buttons.

<div class="flex items-end justify-center relative" data-controller="feedback" data-feedback-expanded-value="false" data-feedback-anchor-point-value="bottom" data-feedback-target="container">
  <!-- Button -->
  <button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-black/10 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" data-feedback-target="button" data-action="click->feedback#toggle">
    <span class="flex items-center gap-1.5" data-feedback-target="buttonText">
      Feedback
    </span>
  </button>

  <!-- Form -->
  <div class="absolute hidden h-32 w-64 sm:h-48 sm:w-96 overflow-hidden rounded-lg border border-black/5 bg-neutral-100 p-1.5 shadow-xs outline-none dark:border-white/10 dark:bg-neutral-900 dark:shadow-neutral-900/50" data-feedback-target="form">
    <form class="flex h-full flex-col overflow-hidden rounded-lg border border-black/10 bg-white dark:border-neutral-600 dark:bg-neutral-700/75" data-action="submit->feedback#submit">

      <textarea class="small-scrollbar w-full flex-1 resize-none rounded-t-lg p-3 text-sm text-black placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400"
                required
                placeholder="Tell us what you think..."
                data-feedback-target="textarea"></textarea>

      <div class="relative flex h-12 items-center border-t border-dashed border-black/10 bg-neutral-50 px-2.5 dark:border-white/20 dark:bg-neutral-800">
        <button type="submit" class="ml-auto flex items-center justify-center rounded-md bg-neutral-800 py-1 px-2 text-xs font-medium text-white transition-colors duration-200 hover:bg-neutral-700 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100">
          <span>
            Send feedback
          </span>
        </button>
      </div>

    </form>
  </div>
</div>

Configuration

The feedback component is powered by a Stimulus controller that provides smooth animations, positioning options, and form handling capabilities using Motion.dev.

Controller Setup

Basic feedback structure with required data attributes:

<div class="relative" data-controller="feedback" data-feedback-anchor-point-value="center">
  <button data-feedback-target="button" data-action="click->feedback#toggle">
    <span data-feedback-target="buttonText">Feedback</span>
  </button>

  <div data-feedback-target="form" class="absolute hidden">
    <form data-action="submit->feedback#submit">
      <textarea data-feedback-target="textarea" placeholder="Your feedback..."></textarea>
      <button type="submit">Send feedback</button>
    </form>
  </div>
</div>

Configuration Values

Prop Description Type Default
anchorPoint
Controls the anchor point from which the form expands relative to the button. Supports: center, top, top-left, top-right, bottom, bottom-left, bottom-right, left, right string center
expanded
Controls the initial state of the feedback form (typically managed automatically) boolean false

Targets

Target Description Required
button
The clickable button element that triggers the feedback form Required
buttonText
The text content inside the button for smooth animation Required
form
The feedback form container that expands from the button Required
textarea
The textarea element for user feedback input Required
container
Optional container element for relative positioning Optional

Actions

Action Description Usage
toggle
Toggles the feedback form open/closed state with smooth animation click->feedback#toggle
submit
Handles form submission, processes feedback, and collapses the form submit->feedback#submit

Anchor Point Options

The feedback component supports 9 different anchor point options for form expansion:

Anchor Point Description Transform Origin
center Form expands from center in all directions center center
top Form expands downward from top center top center
top-left Form expands from top-left corner top left
top-right Form expands from top-right corner top right
bottom Form expands upward from bottom center bottom center
bottom-left Form expands from bottom-left corner bottom left
bottom-right Form expands from bottom-right corner bottom right
left Form expands rightward from center left center left
right Form expands leftward from center right center right

Animation Features

  • Motion.dev Integration: Uses performant spring animations for smooth form expansion and collapse
  • Transform-based Scaling: Efficient scaling animations that avoid layout recalculation
  • Staggered Form Elements: Form inputs animate in with a subtle stagger effect
  • Auto-focus: Textarea automatically receives focus when form expands

Interaction Features

  • Click Outside to Close: Form automatically closes when clicking outside the component
  • Escape Key Support: Press Esc to close the form
  • Form Submission: Handles form submission with automatic collapse and field clearing
  • Turbo Navigation Support: Properly handles Turbo page transitions and caching

Table of contents

Get notified when new components come out