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"
npm install motion
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