Skip to content

feat(header, footer, tab-bar): add scrollEffect prop#31263

Open
OS-susmitabhowmik wants to merge 47 commits into
nextfrom
ROU-12870-scroll-effect
Open

feat(header, footer, tab-bar): add scrollEffect prop#31263
OS-susmitabhowmik wants to merge 47 commits into
nextfrom
ROU-12870-scroll-effect

Conversation

@OS-susmitabhowmik

@OS-susmitabhowmik OS-susmitabhowmik commented Jul 10, 2026

Copy link
Copy Markdown

Issue number: resolves internal


What is the current behavior?

  • ion-header and ion-footer have a collapse prop for scroll-driven effects (condense, fade), but it only works on the iOS theme.
  • ion-tab-bar has an unreleased hideOnScroll boolean prop that only works on the Ionic theme with expand="compact".

What is the new behavior?

  • Adds a new scrollEffect string enum prop to ion-header, ion-footer, and ion-tab-bar.
  • scrollEffect="hide" slides the bar out of view when scrolling down and back in when scrolling up.
  • scrollEffect="condense" and scrollEffect="fade" on ion-header/ion-footer replace the existing collapse values and work across all themes.
  • Deprecates the collapse prop on ion-header and ion-footer with a console warning directing developers to use scrollEffect.
  • Removes the unreleased hideOnScroll prop from ion-tab-bar.
  • When ion-tab-bar is nested inside ion-footer, the footer owns the hide animation to prevent double-animation

Does this introduce a breaking change?

  • Yes
  • No

Other information

Relevant Test Pages:

Header

Footer

Tab Bar

@vercel

vercel Bot commented Jul 10, 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, Comment Jul 17, 2026 11:50pm

Request Review

@github-actions github-actions Bot added package: core @ionic/core package package: angular @ionic/angular package package: vue @ionic/vue package package: react @ionic/react package labels Jul 10, 2026
@OS-susmitabhowmik
OS-susmitabhowmik marked this pull request as ready for review July 13, 2026 17:01
@OS-susmitabhowmik
OS-susmitabhowmik requested a review from a team as a code owner July 13, 2026 17:01
@OS-susmitabhowmik
OS-susmitabhowmik requested a review from ShaneK July 13, 2026 17:01

@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.

Hey, great work on this! I highlighted a few issues I found, some of them aren't as important as others, but still

Comment thread core/src/components/header/header.tsx
Comment thread core/src/utils/content/index.ts Outdated
Comment thread core/src/components/footer/footer.tsx
Comment thread core/src/components/header/header.tsx
Comment thread core/src/components/tab-bar/tab-bar.tsx
Comment thread core/src/components/footer/footer.tsx
Comment thread core/src/components/tab-bar/tab-bar.tsx
Comment thread core/src/utils/scroll-hide-controller.ts

@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.

Hey! Thanks for fixing up those issues! I found some more things that were a bit concerning with this change, let me know if I'm off the mark on anything here!

}
}

private setupScrollEffectHide = async (contentEl: HTMLElement) => {

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.

setupHidePromise only gets nulled in the === promise success branch, but none of the destroy* methods or disconnectedCallback reset it, even though the guard comment says the disconnect case is handled. So if getScrollElement is still pending when the component disconnects (fast nav, tab switch) or scrollEffect switches away from hide, the stale promise still passes the identity check and we build a ScrollHideController + ResizeObserver and re-add the content partner classes, with nothing left to tear them down. I think setting this.setupHidePromise = null inside each destroy* method fixes it, since they run at the top of every checkCollapsible*/checkScrollEffect and in disconnectedCallback (matches the keyboardCtrlPromise handling and the pattern in components.md). Same thing applies to footer.tsx and tab-bar.tsx.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Great catch! I set this.setupHidePromise = null inside each destroy method in this commit 416cf50

const contentEl = pageEl ? findIonContent(pageEl) : null;
// condense/fade via the deprecated `collapse` prop are iOS-only.
// condense/fade via the new `scrollEffect` prop work in all themes.
const isModeRestricted = scrollEffect === undefined && getIonTheme(this) !== 'ios';

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.

scroll-effect="condense" opens up for all themes here, but I think it ends up a visual no-op on md? The JS clones the large title and toggles header-collapse-condense, but only title.ios.scss and title.ionic.scss define :host(.title-large), so on md the large title renders at normal size and there's nothing to collapse. The condense e2e only checks the class/aria-hidden/role, so it passes either way. Was md large-title styling meant to be part of this, or is condense-on-md intentionally out of scope for now? A per-theme screenshot test would lock down whichever way you want it. Let me know if I'm missing something!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for catching this! Previously we didn't have a guard for the large title so the collapse was working. I added a check for the large title in this commit d976f01 and also added large title styling for the md theme in this commit d0ed544. I set the font size to match the large app bar for material design (https://m3.material.io/components/app-bars/specs) but will double check this with product.

const hasHide = (scrollEffect ?? collapse) === 'hide';
const hasFade = (scrollEffect ?? collapse) === 'fade';

this.destroyCollapsibleFooter();

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.

checkCollapsibleFooter runs from componentDidUpdate, and keyboardVisible is @State, so every keyboard show/hide re-renders and this destroyCollapsibleFooter() resets isHidden, strips footer-scroll-hidden, then rebuilds the controller + ResizeObserver. In practice a scroll-effect="hide" footer that you've scrolled away pops back into view the moment you focus an input, and it repeats on every keyboard toggle (losing the scroll hysteresis each time). tab-bar dodges this by driving setup from @Watch('scrollEffect') + componentDidLoad. Could header/footer do the same, or early-return when the resolved effect and content element haven't changed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Great callout, I added an early return in 0ed301f

const { color, translucent, keyboardVisible, scrollEffect, expand } = this;
const theme = getIonTheme(this);
const shape = this.getShape();
const shouldHide = keyboardVisible && this.el.getAttribute('slot') !== 'top';

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.

setHidden sets aria-hidden imperatively for the scroll effect, but render() also owns it for the keyboard case (aria-hidden={shouldHide ? 'true' : null} just below). When a scroll-hidden tab-bar re-renders with shouldHide=false (keyboard closing), Stencil emits aria-hidden={null} and clears it, so the bar ends up inert + off-screen but no longer aria-hidden. inert covers the a11y hiding so it's not a hard break, but it's two writers on one attribute, and header/footer never render aria-hidden at all. Could tab-bar route the keyboard aria-hidden through the same imperative path so there's a single writer? Up to you.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated 3e74e1d

* won't activate it. Without this, the header renders as a
* normal visible header, adding unwanted height to the page.
*/
.header-md[collapse="condense"]:not(.header-collapse-condense) {

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.

One thing I wanted to double check on the deprecated path: collapse isn't a reflected @Prop, and the Angular/Vue proxies bind it as a property, so under [collapse]="'condense'" there's no collapse attribute on the host and .header-md[collapse="condense"] won't match, which I think leaves the ghost header visible on md. header.common.scss already calls this out and targets both the attribute and the class for that reason. Was keying off the attribute a deliberate choice here, or should this match a class the way the :has() rule does?

@OS-susmitabhowmik OS-susmitabhowmik Jul 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated to use the class based approach in 9042365

private setupHideOnScroll() {
const theme = getIonTheme(this);
if (theme !== 'ionic' || !this.hideOnScroll || this.expand !== 'compact') {
private setupScrollEffect = async () => {

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.

setupScrollEffect bails for any ancestor ion-footer, so <ion-footer><ion-tab-bar scroll-effect="hide"> where the footer has no scrollEffect (or fade) ends up hiding nothing, the tab-bar warns and returns and the footer isn't animating either. Letting the footer own the animation makes sense when the footer itself hides, but for a plain footer the prop just does nothing plus a console warning. Is that the intended final behavior? If so I think it's worth documenting on the prop rather than only warning at runtime, and if not, maybe only bail when the footer actually resolves to a hide effect.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I added a comment to document this b44fc68. If only ion-tab-bar is animated to hide, the footer visibly remains behind leaving an empty gap at the bottom of the page.

Screenshot 2026-07-17 at 6 15 43 PM

* same gesture.
*
* When the user reverses scroll direction, we save that position as
* an anchor. The bar only hides/shows after scrolling 60px past that

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.

Nit: the module doc says the bar "only hides/shows after scrolling 60px past that anchor", but showing is immediate (requiredScrollDistance is 0 when scrolling up, and the wheel-up path shows with no distance check). Might be worth dropping the show side from that sentence so it only describes hiding. Up to you!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed fa7d2d0

// to fill the gap so no empty space is visible to the user.

// Header gap compensation — only when content is NOT fullscreen.
// When fullscreen=true, inner-scroll already fills the full viewport

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.

Nit: this reads inverted to me. offset-top = 0 is the non-fullscreen case, not fullscreen. In fullscreen --offset-top is the header height. The gate itself is fine, just the reasoning in the comment. No worries if you'd rather leave it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed fa7d2d0

z-index: 0;
}

.header-collapse-condense ion-toolbar:last-of-type {

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.

Nit: I think this is a leftover from the ios -> common move? .header-collapse-condense ion-toolbar:last-of-type { --border-width: 0px } now lives in header.common.scss too, which the ios bundle pulls in via header.native, so this copy looks redundant. Feel free to drop it if so.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This was intentional, in the ios theme specifically the common style is overwritten by the .header-ios ion-toolbar:last-of-type selector so we need to add it back here.


if (isScrollingDown !== wasScrollingDown) {
this.scrollDirectionChangeTop = this.lastScrollTop;
private destroyScrollEffect() {

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.

Nit: the mechanism converged really nicely across the three, but the method names still differ a bit, tab-bar has setupScrollEffect/destroyScrollEffect where header/footer have setupScrollEffectHide/destroyCollapsible*. Might be worth aligning them (or a quick comment on why tab-bar's differ) so they read the same at a glance. Totally optional.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I added a comment around this but I kept them named differently because the tab bar only supports hide, whereas the header and footer have multiple scroll effects (collapse, fade, hide) fa7d2d0

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

Labels

package: angular @ionic/angular package package: core @ionic/core package package: react @ionic/react package package: vue @ionic/vue package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants