Bohdan Karpliak

Product designer

Tips & tricks

Material-style inputs in Framer with a code override

Framer inputs don’t come with a thin underline and a floating label. A code override built with Claude adds them.


I wanted Material-style inputs in Framer: a thin underline, and a label that floats up when you click into the field.

Framer inputs don’t come with this style by default, so I asked Claude for help. After 2-3 attempts, it gave me a code override that I now apply to each input.

Here is how to set it up:

  1. In Framer, go to Assets > Code, create a new code file, and paste the code below.
  2. Add the default Form component to the canvas.
  3. Remove the default styles from each input you want to customize, so they don’t conflict with the Material styles.
  4. Select an input and apply the override in the Code Overrides section on the right.

The label text comes from the input’s placeholder, so fill in the placeholder for each input.

You may not see the effect on the canvas. Check it in preview or on the published site.

Fonts and colors are constants at the top of the code, so you can match them to your project.

tsx
import { forwardRef, useEffect, useRef } from "react"
import type { ComponentType } from "react"

// Typography (from your text style)
const FONT_FAMILY = `"Inter Tight", sans-serif`
const FONT_WEIGHT = 500
const FONT_SIZE = 18
const LINE_HEIGHT = 24

// Colors
const LABEL_COLOR = "#8C8C8C"
const TEXT_COLOR = "#1a1a1a"
const LINE_COLOR = "#dddddd"
const ACCENT = "#1a1a1a" // label + line on focus

// Spacing
const GAP_TO_LINE = 12 // text line → underline
const LABEL_SCALE = 0.75 // floated label size (18px → 13.5px)
const LABEL_GAP = 2 // floated label → typed text

// Derived
const PAD_TOP = LINE_HEIGHT * LABEL_SCALE + LABEL_GAP // 20
const FIELD_HEIGHT = PAD_TOP + LINE_HEIGHT + GAP_TO_LINE + 1 // 57 (incl. 1px line)

const STYLE_ID = "fl-single-styles-v2"
const CSS = `
.fl-root, .fl-host {
  height: auto !important;
  min-height: 0 !important;
  overflow: visible !important;
  outline: none !important;
  box-shadow: none !important;
}
.fl-host { position: relative !important; }
.fl-host input, .fl-host textarea {
  box-sizing: border-box !important;
  display: block !important;
  width: 100% !important;
  height: ${FIELD_HEIGHT}px;
  background: transparent !important;
  border: 0 !important;
  border-bottom: 1px solid ${LINE_COLOR} !important;
  border-radius: 0 !important;
  box-shadow: none !important;
  outline: none !important;
  resize: none !important;
  overflow: hidden !important;
  margin: 0 !important;
  padding: ${PAD_TOP}px 0 ${GAP_TO_LINE}px 0 !important;
  font-family: ${FONT_FAMILY} !important;
  font-weight: ${FONT_WEIGHT} !important;
  font-size: ${FONT_SIZE}px !important;
  line-height: ${LINE_HEIGHT}px !important;
  color: ${TEXT_COLOR} !important;
  transition: border-color .2s ease;
}
.fl-host input { height: ${FIELD_HEIGHT}px !important; }
.fl-host input::placeholder, .fl-host textarea::placeholder { color: transparent !important; }
.fl-label {
  position: absolute;
  left: 0;
  top: ${PAD_TOP}px;
  margin: 0;
  font-family: ${FONT_FAMILY};
  font-weight: ${FONT_WEIGHT};
  font-size: ${FONT_SIZE}px;
  line-height: ${LINE_HEIGHT}px;
  color: ${LABEL_COLOR};
  white-space: nowrap;
  transform-origin: left top;
  pointer-events: none;
  transition: transform .2s ease, color .2s ease;
}
.fl-host.fl-active .fl-label { transform: translateY(-${PAD_TOP}px) scale(${LABEL_SCALE}); }
.fl-host.fl-focus .fl-label { color: ${ACCENT}; }
.fl-host.fl-focus input, .fl-host.fl-focus textarea { border-bottom-color: ${ACCENT} !important; }
`

export function withFloatingLabel(Component): ComponentType<any> {
    return forwardRef<HTMLElement, any>((props, ref) => {
        const local = useRef<HTMLElement | null>(null)

        const setRef = (el: HTMLElement | null) => {
            local.current = el
            if (typeof ref === "function") ref(el)
            else if (ref) ref.current = el
        }

        useEffect(() => {
            if (!document.getElementById(STYLE_ID)) {
                const style = document.createElement("style")
                style.id = STYLE_ID
                style.textContent = CSS
                document.head.appendChild(style)
            }

            const root = local.current
            if (!root) return
            const input = (
                root.matches("input, textarea")
                    ? root
                    : root.querySelector("input, textarea")
            ) as HTMLInputElement | HTMLTextAreaElement | null
            const host = input?.parentElement
            if (!input || !host || host.querySelector(":scope > .fl-label"))
                return

            const isArea = input.tagName === "TEXTAREA"
            if (isArea) (input as HTMLTextAreaElement).rows = 1

            const label = document.createElement("span")
            label.className = "fl-label"
            label.textContent = input.getAttribute("placeholder") ?? ""
            root.classList.add("fl-root")
            host.classList.add("fl-host")
            input.insertAdjacentElement("afterend", label)

            // Textarea: one line by default, grows with content
            const grow = () => {
                if (!isArea) return
                input.style.setProperty("height", "auto", "important")
                const h = Math.max(FIELD_HEIGHT, input.scrollHeight + 1)
                input.style.setProperty("height", `${h}px`, "important")
            }

            const update = () => {
                const focused = document.activeElement === input
                host.classList.toggle("fl-focus", focused)
                host.classList.toggle(
                    "fl-active",
                    focused || input.value !== ""
                )
                grow()
            }

            input.addEventListener("focus", update)
            input.addEventListener("blur", update)
            input.addEventListener("input", update)
            update()

            return () => {
                input.removeEventListener("focus", update)
                input.removeEventListener("blur", update)
                input.removeEventListener("input", update)
                label.remove()
                root.classList.remove("fl-root")
                host.classList.remove("fl-host", "fl-focus", "fl-active")
            }
        }, [])

        return <Component ref={setRef} {...props} />
    })
}