Skip to content

fix(input): support floating labels with slotted content - #31309

Open
brandyscarney wants to merge 25 commits into
major-9.0from
FW-6471-input
Open

fix(input): support floating labels with slotted content#31309
brandyscarney wants to merge 25 commits into
major-9.0from
FW-6471-input

Conversation

@brandyscarney

@brandyscarney brandyscarney commented Jul 30, 2026

Copy link
Copy Markdown
Member

Issue number: resolves #29449 resolves #28665


What is the current behavior?

Inputs with a floating label and a start or end slot always display the label in the floated state, regardless of whether the input contains a value:

without value with value
without value with value

What is the new behavior?

  • The floating label now behaves consistently regardless of whether start or end slots are present:
    • It overlays the input when the field is empty.
    • It floats when the input is focused or contains a value.
  • The height of inputs with start or end slots has been reduced to match the md specification.
  • Start slot content is now always positioned to the left of both the label and the input.
  • The clear button is now vertically centered within the entire input container, rather than only the input element.
  • Additional screenshot tests have been added to verify these behavior and layout changes.
  • A follow-up ticket has been created to address the remaining UI differences between our leading/trailing content implementation and the md specification.

Does this introduce a breaking change?

  • Yes
  • No

Internal DOM Structure Changes

New wrapper elements have been added to the component's internal DOM structure to support floating labels with slotted start and end content. Additionally, the structure of the component has been reorganized, with some elements now grouped differently than before. This may introduce breaking changes for developers who rely on the component's internal DOM structure or apply custom styling to internal elements.

The following internal wrapper elements have been added:

  • Added: <div class="input-start"> wrapper for the start slot
  • Added: <div class="input-control"> wrapper for the label and native control
  • Added: <div class="input-end"> wrapper for the end slot and clear button

While the public API has not changed, selectors or style overrides targeting the previous markup may need to be updated to reference the new wrapper elements and their organization. If you have custom CSS targeting the internal structure of input, update your selectors to account for these structural changes.

Other information

Preview

@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ionic-framework Ready Ready Preview Aug 7, 2026 9:07pm

Request Review

@github-actions github-actions Bot added the package: core @ionic/core package label Jul 30, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed these screenshots from input-slots to input-slot to match the folder name.


configs().forEach(({ title, screenshot, config }) => {
test.describe(title('input: start and end slots (visual checks)'), () => {
test.describe(title('input: slot'), () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was updated to match the folder name, following how we title other tests.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the correct height and label position for Material Design filled inputs. See: https://m2.material.io/components/text-fields

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This 1px shift was caused by the removal of this rule:

/**
* Ensures the input does not
* overlap the label.
*/
:host(.input-label-placement-stacked) input,
:host(.input-label-placement-floating) input {
@include margin(1px, 0, 0, 0);
}

However, this is actually more aligned than before:

Image

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This 1px shift was caused by the removal of this rule:

/**
* Ensures the input does not
* overlap the label.
*/
:host(.input-label-placement-stacked) input,
:host(.input-label-placement-floating) input {
@include margin(1px, 0, 0, 0);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This 1px shift was caused by the removal of this rule:

/**
* Ensures the input does not
* overlap the label.
*/
:host(.input-label-placement-stacked) input,
:host(.input-label-placement-floating) input {
@include margin(1px, 0, 0, 0);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This 1px shift was caused by the removal of this rule:

/**
* Ensures the input does not
* overlap the label.
*/
:host(.input-label-placement-stacked) input,
:host(.input-label-placement-floating) input {
@include margin(1px, 0, 0, 0);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clear input button was updated to be vertically centered based on the entire input container. This aligns with how md treats any trailing icon/button:

Image

@brandyscarney brandyscarney changed the title fix(input): float label when start and end slot exist fix(input): support floating labels with slotted content Jul 31, 2026

@ShaneK ShaneK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking really great! I had some feedback on a few issues and some minor things, but really looking good!

* Only applies to inputs with `md` mode and `fill="outline"`.
* In RTL mode, the adjustment is positive; in LTR mode, it's negative.
*/
private getStartSlotAdjustment(): string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling getBoundingClientRect() from inside render() means the value only refreshes when something triggers a re-render, and it forces a sync layout on every render, including focus, blur and value changes that have nothing to do with slots.

The part that doesn't recover is a start slot changing width without a DOM mutation. Nothing re-measures and the label just stays wrong. Both of these have the same start slot width, the bottom one only got there after mount, which is what a late loading web font does:

image

There's a smaller version of this on first paint too, where .input-start doesn't exist yet so the first render gets an empty string and the label slides about 32px into place over the 150ms transition. That one's easy to miss while the form is still appearing, so the stale case is the one I'd worry about.

The NotchController already solves this shape for the notch: it defers with raf(), measures, then writes with style.setProperty(), driven from componentDidRender() which this component already calls. A ResizeObserver on .input-start would cover the width change case as well.

* The solid and outline fills are only supported by `md` mode.
*/
configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input: slot'), () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two functionality tests came out and nothing replaced them, so this file is all screenshots now. hasValue is covered eight ways but hasFocus isn't covered at all, and floating on focus is the headline behaviour of the fix.

A few other gaps: end slot only, which is exactly what the removed test covered, stacked, and the runtime add/remove slot path that skipLabelTransition exists for. Both test/color and test/highlight set class="has-focus" on the host for deterministic focus screenshots if that helps.

Since the contract inverted rather than disappeared, could the old assertion come back the other way round, something like not.toHaveClass(/label-floating/) when the slots are populated and the input is empty? A screenshot diff tells you something moved, but not which rule broke.

--border-radius: 4px;
--padding-start: 16px;
--padding-end: 16px;
--start-slot-adjustment: 0px;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this get the same private API comment as --highlight-color? It's sitting right next to --border-radius and --padding-start which are both @prop documented, so as it is now it looks like something people can set.

Also wondering about a fallback on the var(). Without one a missing property invalidates the whole transform, so the label would lose its scale() too and not just the translate.

@brandyscarney brandyscarney Aug 7, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we decided we would mark internal css variables with --internal for modular Ionic I chose to do that instead of adding a comment. I also don't think the fallback should be reached since it is set by default here, but I added it anyway.

Input fix: 4aedde9

Textarea fix: a595a64

Select fix: ffe8326

Comment thread core/src/components/input/input.tsx Outdated
*/
this.skipLabelTransition = true;

requestAnimationFrame(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a raf() helper in @utils/helpers that's the zone-patched wrapper, and it's what the notch controller this component already depends on uses. Any reason not to use it here?

This also isn't cancelled in disconnectedCallback(), so a slot mutation landing in the same frame the input is removed will set state on a torn down component. Minor, but the other controllers are cleaned up there already.

@brandyscarney brandyscarney Aug 7, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Input fix: 3fcf658

Textarea fix: 6278723

Select fix: 9a7b423

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package: core @ionic/core package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants