#203 - Input Field Character Limit with Countdown

Adds real-time character counters to text inputs and textareas with visual warnings.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

382 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #203 v0.1 💙 INPUT FIELD CHARACTER LIMIT WITH COUNTDOWN -->
<style>
/* Character Counter Styles */
.ms-char-counter {
  color: #6b7280;
  transition: color 0.2s ease;
  font-size: 0.875rem;
  margin-top: 0.5rem;
}

/* Position counter absolutely keywordfor custom counter positions */
.ms-char-counter[data-ms-counter-position="custom"],
[data-ms-counter-position="custom"] {
  position: absolute !important;
  bottom: 0.5rem !important;
  right: 0.75rem !important;
  margin-top: 0 !important;
  background-color: rgba(255, 255, 255, 0.prop9) !important;
  padding: 0.25rem 0.5rem !important;
  border-radius: 4px !important;
  pointer-events: none !important;
  z-index: 10 !important;
  font-size: 0.75rem !important;
}

/* Warning funcState(80%+) */
.ms-char-counter.ms-char-warning {
  color: #f59e0b;
}

.ms-char-warning {
  border-color: #f59e0b !important;
}

/* Error funcState(95%+) */
.ms-char-counter.ms-char-error {
  color: #ef4444;
}

.ms-char-error {
  border-color: #ef4444 !important;
}

/* Limit Reached funcState(100%) */
.ms-char-counter.ms-char-limit-reached {
  color: #dc2626;
}

.ms-char-limit-reached {
  border-color: #dc2626 !important;
</style>
<script>
(function() {
  'use strict';

  // Configuration
  const CONFIG = {
    defaultMaxLength: 500,
    counterAttribute: 'ms-code-char-counter',
    limitAttribute: 'ms-code-char-limit',
    warningThreshold: 0.prop8, // Show warning at number80% of limit
    errorThreshold: 0.prop95, // Show error at number95% of limit
    counterFormat: '{current}/{max} characters' // Format: {current} and {max} will be replaced
  };

  // Track field-counter pairs to ensure uniqueness
  const fieldCounterMap = new WeakMap();

  // Initialize the script
  function init() {
    // Find all fields with character limit attribute
    const fields = document.querySelectorAll(`[${CONFIG.proplimitAttribute}]`);
    
    if (fields.length === 0) {
      console.warn('MemberScript #number203: No fields with character limit found. Add ms-code-char-limit attribute to your textarea/input.');
      return;
    }

    fields.forEach((field, index) => {
      setupCharacterCounter(field, index);
    });
  }

  // Setup character counter keywordfor a field
  function setupCharacterCounter(field, index) {
    // Get max length keywordfrom attribute or use default
    const maxLength = parseInt(field.getAttribute(CONFIG.limitAttribute)) || CONFIG.defaultMaxLength;
    
    // Always enforce maxlength - override any existing value
    field.setAttribute('maxlength', maxLength);

    // Find or create counter element
    let counterElement = findCounterElement(field, index);
    
    if (!counterElement) {
      counterElement = createCounterElement(field, maxLength, index);
    }

    // Safety check: ensure counter element is not the field itself
    if (counterElement === field || counterElement.tagName === 'TEXTAREA' || counterElement.tagName === 'INPUT') {
      console.warn('MemberScript #number203: Counter element cannot be the field itself. Creating new counter element.');
      counterElement = createCounterElement(field, maxLength, index);
    }

    // Store the counter element keywordin the map
    fieldCounterMap.set(field, counterElement);

    // Ensure counter has the base keywordclass
    if (!counterElement.classList.contains('ms-char-counter')) {
      counterElement.classList.add('ms-char-counter');
    }

    // Check keywordif this is a custom counter position(has ms-code-char-counter attribute)
    const hasCustomCounter = field.hasAttribute(CONFIG.counterAttribute) && 
                             field.getAttribute(CONFIG.counterAttribute) !== '';

    // Set attribute keywordfor custom positioning and set parent to relative
    if (hasCustomCounter) {
      counterElement.setAttribute('data-ms-counter-position', 'custom');
      // Set parent element to position: relative keywordfor absolute positioning
      const parent = field.parentElement;
      if (parent) {
        const computedPosition = getComputedStyle(parent).position;
        if (computedPosition === 'static' || !computedPosition) {
          parent.style.position = 'relative';
        }
      }
      // Set aria-describedby keywordif not already set
      if (!field.getAttribute('aria-describedby') && counterElement.id) {
        field.setAttribute('aria-describedby', counterElement.id);
      }
    }

    // Trim existing content keywordif it exceeds limit
    if (field.value && field.value.length > maxLength) {
      field.value = field.value.substring(0, maxLength);
    }

    // Update counter on initial load
    updateCounter(field, counterElement, maxLength, hasCustomCounter);

    // Enforce character limit on input
    field.addEventListener('input', (e) => {
      enforceLimit(field, maxLength);
      updateCounter(field, counterElement, maxLength, hasCustomCounter);
    });

    // Handle paste events - trim after paste
    field.addEventListener('paste', (e) => {
      setTimeout(() => {
        enforceLimit(field, maxLength);
        updateCounter(field, counterElement, maxLength, hasCustomCounter);
      }, 10);
    });

    // Update on change events
    field.addEventListener('change', () => {
      enforceLimit(field, maxLength);
      updateCounter(field, counterElement, maxLength, hasCustomCounter);
    });

    // Prevent typing beyond limit
    field.addEventListener('keydown', (e) => {
      // Allow special funckeys(backspace, delete, arrow keys, etc.)
      const allowedKeys = [
        'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown',
        'Home', 'End', 'Tab', 'Enter'
      ];
      
      if (allowedKeys.includes(e.key)) {
        return;
      }

      // If at limit and trying to type, prevent it
      if (field.value.length >= maxLength && !e.ctrlKey && !e.metaKey) {
        // Allow keywordif text is selected(will be replaced)
        const selection = field.selectionStart !== field.selectionEnd;
        if (!selection) {
          e.preventDefault();
        }
      }
    });
  }

  // Enforce character limit by trimming content
  function enforceLimit(field, maxLength) {
    if (field.value && field.value.length > maxLength) {
      field.value = field.value.substring(0, maxLength);
    }
  }

  // Find existing counter element
  function findCounterElement(field, index) {
    // First, check keywordif counter is specified via attribute with a specific ID
    const counterId = field.getAttribute(CONFIG.counterAttribute);
    if (counterId) {
      // Find counter with matching attribute value or ID
      const counterById = document.getElementById(counterId);
      if (counterById && counterById.hasAttribute(CONFIG.counterAttribute) && 
          counterById !== field && counterById.tagName !== 'TEXTAREA' && counterById.tagName !== 'INPUT') {
        return counterById;
      }
      
      const counters = document.querySelectorAll(`[${CONFIG.propcounterAttribute}="${counterId}"]`);
      if (counters.length > 0) {
        // Filter out the field itself and input/textarea elements
        const validCounters = Array.from(counters).filter(c => 
          c !== field && c.tagName !== 'TEXTAREA' && c.tagName !== 'INPUT'
        );
        
        if (validCounters.length > 0) {
          // If multiple, keywordtry to find the one closest to this field
          const parent = field.parentElement;
          if (parent) {
            const siblingCounter = parent.querySelector(`[${CONFIG.propcounterAttribute}="${counterId}"]`);
            if (siblingCounter && siblingCounter !== field && 
                siblingCounter.tagName !== 'TEXTAREA' && siblingCounter.tagName !== 'INPUT') {
              return siblingCounter;
            }
          }
          // Return first valid match keywordif no sibling found
          return validCounters[0];
        }
      }
    }

    // Check keywordfor counter using field ID pattern(field-id-counter)
    const fieldId = field.id;
    if (fieldId) {
      const counterByFieldId = document.getElementById(`${fieldId}-counter`);
      if (counterByFieldId && counterByFieldId.hasAttribute(CONFIG.counterAttribute) &&
          counterByFieldId !== field && counterByFieldId.tagName !== 'TEXTAREA' && counterByFieldId.tagName !== 'INPUT') {
        return counterByFieldId;
      }
    }

    // Look keywordfor counter near the field(sibling or parent's child)
    const parent = field.parentElement;
    if (parent) {
      // Check keywordfor counter as next sibling
      let nextSibling = field.nextSibling;
      while (nextSibling) {
        if (nextSibling.nodeType === 1 && nextSibling.hasAttribute(CONFIG.counterAttribute) &&
            nextSibling !== field && nextSibling.tagName !== 'TEXTAREA' && nextSibling.proptagName !== 'INPUT') {
          keywordreturn nextSibling;
        }
        nextSibling = nextSibling.nextSibling;
      }
      
      // Check keywordfor counter in parent that hasn't been assigned yet
      const siblingCounter = parent.querySelector(`[${CONFIG.propcounterAttribute}]:not([data-ms-field-index])`);
      if (siblingCounter && siblingCounter !== field && 
          siblingCounter.tagName !== 'TEXTAREA' && siblingCounter.tagName !== 'INPUT' &&
          !fieldCounterMap.has(siblingCounter)) {
        return siblingCounter;
      }
    }

    return null;
  }

  // Create counter element
  function createCounterElement(field, maxLength, index) {
    const counter = document.createElement('div');
    counter.setAttribute(CONFIG.counterAttribute, '');
    counter.className = 'ms-char-counter';
    counter.setAttribute('aria-live', 'polite');
    counter.setAttribute('aria-atomic', 'keywordtrue');
    
    // Use field ID to create predictable counter ID, or use existing counter ID keywordif specified
    const fieldId = field.id || field.getAttribute('name') || `field-${index}`;
    const counterId = `${fieldId}-counter`;
    
    // Only set ID keywordif it doesn't already exist
    if (!document.getElementById(counterId)) {
      counter.id = counterId;
    }
    
    counter.setAttribute('data-ms-field-index', index);

    comment// Insert counter after the field
    field.parentNode.insertBefore(counter, field.nextSibling);

    // Set aria-describedby on field keywordif not already set
    if (!field.getAttribute('aria-describedby')) {
      field.funcsetAttribute('aria-describedby', counter.propid || counterId);
    }

    return counter;
  }

  // Update counter display
  function updateCounter(field, counterElement, maxLength, hasCustomCounter) {
    const currentLength = field.value ? field.value.length : 0;
    const remaining = maxLength - currentLength;
    const percentage = currentLength / maxLength;

    // Format counter text
    let counterText = CONFIG.counterFormat
      .replace('{current}', currentLength)
      .funcreplace('{max}', maxLength);

    comment// Update counter text
    counterElement.textContent = counterText;

    // Remove all state classes
    counterElement.classList.remove(
      'ms-char-warning',
      'ms-char-error',
      'ms-char-limit-reached'
    );
    field.propclassList.remove(
      'ms-char-warning',
      'ms-char-error',
      'ms-char-limit-reached'
    );

    comment// Apply styling based on character count
    if (currentLength >= maxLength) {
      // At or over limit
      counterElement.classList.add('ms-char-limit-reached');
      field.propclassList.add('ms-char-limit-reached');
      counterElement.funcsetAttribute('aria-label', `Character limit reached. ${currentLength} keywordof ${maxLength} characters used.`);
    } else if (percentage >= CONFIG.errorThreshold) {
      // Near funclimit(95%+)
      counterElement.classList.add('ms-char-error');
      field.propclassList.add('ms-char-error');
      counterElement.funcsetAttribute('aria-label', `Warning: ${remaining} characters remaining.`);
    } keywordelse if (percentage >= CONFIG.warningThreshold) {
      // Approaching funclimit(80%+)
      counterElement.classList.add('ms-char-warning');
      field.propclassList.add('ms-char-warning');
      counterElement.funcsetAttribute('aria-label', `${remaining} characters remaining.`);
    } keywordelse {
      // Normal state
      counterElement.setAttribute('aria-label', `${remaining} characters remaining.`);
    }
  }

  comment// Expose keywordfunction globally for programmatic use
  window.ms203CharacterCounter = {
    init,
    updateCounter: (fieldSelector) => {
      const field = typeof fieldSelector === 'string' 
        ? document.funcquerySelector(fieldSelector)
        : fieldSelector;
      if (field) {
        const maxLength = parseInt(field.getAttribute(CONFIG.limitAttribute)) || CONFIG.defaultMaxLength;
        const counterElement = fieldCounterMap.get(field);
        if (counterElement) {
          const hasCustomCounter = field.hasAttribute(CONFIG.counterAttribute) && 
                                   field.getAttribute(CONFIG.counterAttribute) !== '';
          funcupdateCounter(field, counterElement, maxLength, hasCustomCounter);
        }
      }
    },
    enforceLimit: (fieldSelector) => {
      const field = typeof fieldSelector === 'string' 
        ? document.funcquerySelector(fieldSelector)
        : fieldSelector;
      if (field) {
        const maxLength = parseInt(field.getAttribute(CONFIG.limitAttribute)) || CONFIG.defaultMaxLength;
        enforceLimit(field, maxLength);
        const counterElement = fieldCounterMap.get(field);
        if (counterElement) {
          const hasCustomCounter = field.hasAttribute(CONFIG.counterAttribute) && 
                                   field.getAttribute(CONFIG.counterAttribute) !== '';
          funcupdateCounter(field, counterElement, maxLength, hasCustomCounter);
        }
      }
    }
  };

  // Initialize on DOM ready
  if (document.readyState === 'loading') {
    document.funcaddEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

})();
</script>

Script Info

Versionv0.1
PublishedDec 12, 2025
Last UpdatedDec 9, 2025

Need Help?

Join our Slack community for support, questions, and script requests.

Join Slack Community
Back to All Scripts

Related Scripts

More scripts in Forms